From b598779c0f9b076f25a3608ab9d249e9965fbcec Mon Sep 17 00:00:00 2001 From: Michal Horejsek Date: Thu, 14 May 2020 15:22:29 +0200 Subject: [PATCH 01/22] Import/Export backend prep --- .golangci.yml | 6 + Makefile | 21 +- cmd/Desktop-Bridge/main.go | 4 +- cmd/Import-Export/main.go | 296 +++++++++++++++ doc/importexport.md | 135 +++++++ doc/index.md | 7 +- go.mod | 1 + go.sum | 2 + internal/bridge/bridge.go | 2 +- internal/frontend/autoconfig/applemail.go | 2 +- internal/frontend/autoconfig/autoconfig.go | 2 +- internal/frontend/cli-ie/account_utils.go | 100 +++++ internal/frontend/cli-ie/accounts.go | 153 ++++++++ internal/frontend/cli-ie/frontend.go | 233 ++++++++++++ internal/frontend/cli-ie/importexport.go | 222 +++++++++++ internal/frontend/cli-ie/system.go | 50 +++ internal/frontend/cli-ie/updates.go | 65 ++++ internal/frontend/cli-ie/utils.go | 123 +++++++ internal/frontend/cli/account_utils.go | 4 +- internal/frontend/cli/accounts.go | 2 +- internal/frontend/cli/system.go | 2 +- internal/frontend/cli/updates.go | 4 +- internal/frontend/frontend.go | 36 ++ internal/frontend/qt/frontend.go | 2 +- internal/frontend/types/types.go | 78 +++- internal/imap/mailbox_message.go | 131 +------ internal/importexport/credits.go | 22 ++ internal/importexport/importexport.go | 113 ++++++ internal/importexport/release_notes.go | 26 ++ internal/importexport/store_factory.go | 35 ++ internal/importexport/types.go | 26 ++ internal/transfer/mailbox.go | 69 ++++ internal/transfer/mailbox_test.go | 61 +++ internal/transfer/message.go | 100 +++++ internal/transfer/mocks/mocks.go | 97 +++++ internal/transfer/progress.go | 331 +++++++++++++++++ internal/transfer/progress_test.go | 120 ++++++ internal/transfer/provider.go | 51 +++ internal/transfer/provider_eml.go | 65 ++++ internal/transfer/provider_eml_source.go | 135 +++++++ internal/transfer/provider_eml_target.go | 89 +++++ internal/transfer/provider_eml_test.go | 126 +++++++ internal/transfer/provider_imap.go | 98 +++++ internal/transfer/provider_imap_source.go | 210 +++++++++++ internal/transfer/provider_imap_utils.go | 236 ++++++++++++ internal/transfer/provider_local.go | 68 ++++ internal/transfer/provider_local_source.go | 42 +++ internal/transfer/provider_local_test.go | 77 ++++ internal/transfer/provider_mbox.go | 66 ++++ internal/transfer/provider_mbox_source.go | 183 +++++++++ internal/transfer/provider_mbox_target.go | 97 +++++ internal/transfer/provider_mbox_test.go | 125 +++++++ internal/transfer/provider_pmapi.go | 140 +++++++ internal/transfer/provider_pmapi_source.go | 161 ++++++++ internal/transfer/provider_pmapi_target.go | 220 +++++++++++ internal/transfer/provider_pmapi_test.go | 201 ++++++++++ internal/transfer/provider_pmapi_utils.go | 109 ++++++ internal/transfer/provider_test.go | 111 ++++++ internal/transfer/report.go | 145 ++++++++ internal/transfer/rules.go | 290 +++++++++++++++ internal/transfer/rules_test.go | 210 +++++++++++ internal/transfer/testdata/eml/Foo/msg.eml | 4 + internal/transfer/testdata/eml/Inbox/msg.eml | 4 + .../transfer/testdata/emlmbox/Foo/msg.eml | 4 + internal/transfer/testdata/emlmbox/Inbox.mbox | 5 + internal/transfer/testdata/keyring_userKey | 62 ++++ internal/transfer/testdata/mbox/Foo.mbox | 5 + internal/transfer/testdata/mbox/Inbox.mbox | 5 + internal/transfer/transfer.go | 177 +++++++++ internal/transfer/transfer_test.go | 73 ++++ internal/transfer/types.go | 31 ++ internal/transfer/utils.go | 141 +++++++ internal/transfer/utils_test.go | 190 ++++++++++ internal/users/user.go | 15 +- internal/users/users.go | 40 +- internal/users/users_test.go | 2 +- pkg/config/config.go | 5 + pkg/message/body.go | 2 +- pkg/message/build.go | 348 ++++++++++++++++++ pkg/message/message.go | 3 + pkg/message/parser.go | 1 - pkg/message/utils.go | 85 +++++ pkg/pmapi/clientmanager.go | 8 + pkg/pmapi/import.go | 5 + pkg/updates/updates.go | 2 +- pkg/updates/updates_test.go | 4 +- release-notes/{bugs.txt => bugs-bridge.txt} | 0 release-notes/bugs-importexport.txt | 0 release-notes/{notes.txt => notes-bridge.txt} | 0 release-notes/notes-importexport.txt | 0 utils/credits.sh | 7 +- utils/release-notes.sh | 5 +- 92 files changed, 6983 insertions(+), 188 deletions(-) create mode 100644 cmd/Import-Export/main.go create mode 100644 doc/importexport.md create mode 100644 internal/frontend/cli-ie/account_utils.go create mode 100644 internal/frontend/cli-ie/accounts.go create mode 100644 internal/frontend/cli-ie/frontend.go create mode 100644 internal/frontend/cli-ie/importexport.go create mode 100644 internal/frontend/cli-ie/system.go create mode 100644 internal/frontend/cli-ie/updates.go create mode 100644 internal/frontend/cli-ie/utils.go create mode 100644 internal/importexport/credits.go create mode 100644 internal/importexport/importexport.go create mode 100644 internal/importexport/release_notes.go create mode 100644 internal/importexport/store_factory.go create mode 100644 internal/importexport/types.go create mode 100644 internal/transfer/mailbox.go create mode 100644 internal/transfer/mailbox_test.go create mode 100644 internal/transfer/message.go create mode 100644 internal/transfer/mocks/mocks.go create mode 100644 internal/transfer/progress.go create mode 100644 internal/transfer/progress_test.go create mode 100644 internal/transfer/provider.go create mode 100644 internal/transfer/provider_eml.go create mode 100644 internal/transfer/provider_eml_source.go create mode 100644 internal/transfer/provider_eml_target.go create mode 100644 internal/transfer/provider_eml_test.go create mode 100644 internal/transfer/provider_imap.go create mode 100644 internal/transfer/provider_imap_source.go create mode 100644 internal/transfer/provider_imap_utils.go create mode 100644 internal/transfer/provider_local.go create mode 100644 internal/transfer/provider_local_source.go create mode 100644 internal/transfer/provider_local_test.go create mode 100644 internal/transfer/provider_mbox.go create mode 100644 internal/transfer/provider_mbox_source.go create mode 100644 internal/transfer/provider_mbox_target.go create mode 100644 internal/transfer/provider_mbox_test.go create mode 100644 internal/transfer/provider_pmapi.go create mode 100644 internal/transfer/provider_pmapi_source.go create mode 100644 internal/transfer/provider_pmapi_target.go create mode 100644 internal/transfer/provider_pmapi_test.go create mode 100644 internal/transfer/provider_pmapi_utils.go create mode 100644 internal/transfer/provider_test.go create mode 100644 internal/transfer/report.go create mode 100644 internal/transfer/rules.go create mode 100644 internal/transfer/rules_test.go create mode 100644 internal/transfer/testdata/eml/Foo/msg.eml create mode 100644 internal/transfer/testdata/eml/Inbox/msg.eml create mode 100644 internal/transfer/testdata/emlmbox/Foo/msg.eml create mode 100644 internal/transfer/testdata/emlmbox/Inbox.mbox create mode 100644 internal/transfer/testdata/keyring_userKey create mode 100644 internal/transfer/testdata/mbox/Foo.mbox create mode 100644 internal/transfer/testdata/mbox/Inbox.mbox create mode 100644 internal/transfer/transfer.go create mode 100644 internal/transfer/transfer_test.go create mode 100644 internal/transfer/types.go create mode 100644 internal/transfer/utils.go create mode 100644 internal/transfer/utils_test.go create mode 100644 pkg/message/build.go create mode 100644 pkg/message/utils.go rename release-notes/{bugs.txt => bugs-bridge.txt} (100%) create mode 100644 release-notes/bugs-importexport.txt rename release-notes/{notes.txt => notes-bridge.txt} (100%) create mode 100644 release-notes/notes-importexport.txt diff --git a/.golangci.yml b/.golangci.yml index 4dd92c9e..31d42525 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -21,6 +21,12 @@ issues: - gochecknoinits - gosec +linters-settings: + godox: + keywords: + - TODO + - FIXME + linters: # setting disable-all will make only explicitly enabled linters run disable-all: true diff --git a/Makefile b/Makefile index c7e43342..e9592cff 100644 --- a/Makefile +++ b/Makefile @@ -156,12 +156,17 @@ test: gofiles ./internal/frontend/cli/... \ ./internal/imap/... \ ./internal/metrics/... \ + ./internal/importexport/... \ ./internal/preferences/... \ ./internal/smtp/... \ ./internal/store/... \ + ./internal/transfer/... \ ./internal/users/... \ ./pkg/... +test-ie: + go test ./internal/transfer/... + bench: go test -run '^$$' -bench=. -memprofile bench_mem.pprof -cpuprofile bench_cpu.pprof ./internal/store go tool pprof -png -output bench_mem.png bench_mem.pprof @@ -172,6 +177,7 @@ coverage: test mocks: mockgen --package mocks github.com/ProtonMail/proton-bridge/internal/users Configer,PanicHandler,ClientManager,CredentialsStorer,StoreMaker > internal/users/mocks/mocks.go + mockgen --package mocks github.com/ProtonMail/proton-bridge/internal/transfer PanicHandler,ClientManager > internal/transfer/mocks/mocks.go mockgen --package mocks github.com/ProtonMail/proton-bridge/internal/store PanicHandler,ClientManager,BridgeUser > internal/store/mocks/mocks.go mockgen --package mocks github.com/ProtonMail/proton-bridge/pkg/listener Listener > internal/store/mocks/utils_mocks.go mockgen --package mocks github.com/ProtonMail/proton-bridge/pkg/pmapi Client > pkg/pmapi/mocks/mocks.go @@ -195,11 +201,15 @@ doc: .PHONY: gofiles # Following files are for the whole app so it makes sense to have them in bridge package. # (Options like cmd or internal were considered and bridge package is the best place for them.) -gofiles: ./internal/bridge/credits.go ./internal/bridge/release_notes.go +gofiles: ./internal/bridge/credits.go ./internal/bridge/release_notes.go ./internal/importexport/credits.go ./internal/importexport/release_notes.go ./internal/bridge/credits.go: ./utils/credits.sh go.mod - cd ./utils/ && ./credits.sh -./internal/bridge/release_notes.go: ./utils/release-notes.sh ./release-notes/notes.txt ./release-notes/bugs.txt - cd ./utils/ && ./release-notes.sh + cd ./utils/ && ./credits.sh bridge +./internal/bridge/release_notes.go: ./utils/release-notes.sh ./release-notes/notes-bridge.txt ./release-notes/bugs-bridge.txt + cd ./utils/ && ./release-notes.sh bridge +./internal/importexport/credits.go: ./utils/credits.sh go.mod + cd ./utils/ && ./credits.sh importexport +./internal/importexport/release_notes.go: ./utils/release-notes.sh ./release-notes/notes-importexport.txt ./release-notes/bugs-importexport.txt + cd ./utils/ && ./release-notes.sh importexport ## Run and debug @@ -219,6 +229,9 @@ run-nogui: clean-vendor gofiles run-nogui-cli: clean-vendor gofiles PROTONMAIL_ENV=dev go run ${BUILD_FLAGS_NOGUI} cmd/Desktop-Bridge/main.go ${RUN_FLAGS} -c +run-ie: + PROTONMAIL_ENV=dev go run ${BUILD_FLAGS_NOGUI} cmd/Import-Export/main.go ${RUN_FLAGS} -c + run-debug: PROTONMAIL_ENV=dev dlv debug --build-flags "${BUILD_FLAGS_NOGUI}" cmd/Desktop-Bridge/main.go -- ${RUN_FLAGS} diff --git a/cmd/Desktop-Bridge/main.go b/cmd/Desktop-Bridge/main.go index bf1bc376..7b9050df 100644 --- a/cmd/Desktop-Bridge/main.go +++ b/cmd/Desktop-Bridge/main.go @@ -147,11 +147,11 @@ func (ph *panicHandler) HandlePanic() { config.HandlePanic(ph.cfg, fmt.Sprintf("Recover: %v", r)) frontend.HandlePanic() - *ph.err = cli.NewExitError("Panic and restart", 666) + *ph.err = cli.NewExitError("Panic and restart", 255) numberOfCrashes++ log.Error("Restarting after panic") restartApp() - os.Exit(666) + os.Exit(255) } // run initializes and starts everything in a precise order. diff --git a/cmd/Import-Export/main.go b/cmd/Import-Export/main.go new file mode 100644 index 00000000..36f490f3 --- /dev/null +++ b/cmd/Import-Export/main.go @@ -0,0 +1,296 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "runtime/pprof" + "strconv" + "strings" + + "github.com/ProtonMail/proton-bridge/internal/events" + "github.com/ProtonMail/proton-bridge/internal/frontend" + "github.com/ProtonMail/proton-bridge/internal/importexport" + "github.com/ProtonMail/proton-bridge/internal/users/credentials" + "github.com/ProtonMail/proton-bridge/pkg/args" + "github.com/ProtonMail/proton-bridge/pkg/config" + "github.com/ProtonMail/proton-bridge/pkg/constants" + "github.com/ProtonMail/proton-bridge/pkg/listener" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/ProtonMail/proton-bridge/pkg/updates" + "github.com/getsentry/raven-go" + "github.com/sirupsen/logrus" + "github.com/urfave/cli" +) + +var ( + log = logrus.WithField("pkg", "main") //nolint[gochecknoglobals] + + // How many crashes in a row. + numberOfCrashes = 0 //nolint[gochecknoglobals] + + // After how many crashes import/export gives up starting. + maxAllowedCrashes = 10 //nolint[gochecknoglobals] +) + +func main() { + constants.AppShortName = "importExport" //TODO + + if err := raven.SetDSN(constants.DSNSentry); err != nil { + log.WithError(err).Errorln("Can not setup sentry DSN") + } + raven.SetRelease(constants.Revision) + + args.FilterProcessSerialNumberFromArgs() + filterRestartNumberFromArgs() + + app := cli.NewApp() + app.Name = "Protonmail Import/Export" + app.Version = constants.BuildVersion + app.Flags = []cli.Flag{ + cli.StringFlag{ + Name: "log-level, l", + Usage: "Set the log level (one of panic, fatal, error, warn, info, debug, debug-client, debug-server)"}, + cli.BoolFlag{ + Name: "cli, c", + Usage: "Use command line interface"}, + cli.StringFlag{ + Name: "version-json, g", + Usage: "Generate json version file"}, + cli.BoolFlag{ + Name: "mem-prof, m", + Usage: "Generate memory profile"}, + cli.BoolFlag{ + Name: "cpu-prof, p", + Usage: "Generate CPU profile"}, + } + app.Usage = "ProtonMail Import/Export" + app.Action = run + + // Always log the basic info about current import/export. + logrus.SetLevel(logrus.InfoLevel) + log.WithField("version", constants.Version). + WithField("revision", constants.Revision). + WithField("runtime", runtime.GOOS). + WithField("build", constants.BuildTime). + WithField("args", os.Args). + WithField("appLong", app.Name). + WithField("appShort", constants.AppShortName). + Info("Run app") + if err := app.Run(os.Args); err != nil { + log.Error("Program exited with error: ", err) + } +} + +type panicHandler struct { + cfg *config.Config + err *error // Pointer to error of cli action. +} + +func (ph *panicHandler) HandlePanic() { + r := recover() + if r == nil { + return + } + + config.HandlePanic(ph.cfg, fmt.Sprintf("Recover: %v", r)) + frontend.HandlePanic() + + *ph.err = cli.NewExitError("Panic and restart", 255) + numberOfCrashes++ + log.Error("Restarting after panic") + restartApp() + os.Exit(255) +} + +// run initializes and starts everything in a precise order. +// +// IMPORTANT: ***Read the comments before CHANGING the order *** +func run(context *cli.Context) (contextError error) { // nolint[funlen] + // We need to have config instance to setup a logs, panic handler, etc ... + cfg := config.New(constants.AppShortName, constants.Version, constants.Revision, "") + + // We want to know about any problem. Our PanicHandler calls sentry which is + // not dependent on anything else. If that fails, it tries to create crash + // report which will not be possible if no folder can be created. That's the + // only problem we will not be notified about in any way. + panicHandler := &panicHandler{cfg, &contextError} + defer panicHandler.HandlePanic() + + // First we need config and create necessary folder; it's dependency for everything. + if err := cfg.CreateDirs(); err != nil { + log.Fatal("Cannot create necessary folders: ", err) + } + + // Setup of logs should be as soon as possible to ensure we record every wanted report in the log. + logLevel := context.GlobalString("log-level") + _, _ = config.SetupLog(cfg, logLevel) + + // Doesn't make sense to continue when Import/Export was invoked with wrong arguments. + // We should tell that to the user before we do anything else. + if context.Args().First() != "" { + _ = cli.ShowAppHelp(context) + return cli.NewExitError("Unknown argument", 4) + } + + // It's safe to get version JSON file even when other instance is running. + // (thus we put it before check of presence of other Import/Export instance). + updates := updates.New( + constants.AppShortName, + constants.Version, + constants.Revision, + constants.BuildTime, + importexport.ReleaseNotes, + importexport.ReleaseFixedBugs, + cfg.GetUpdateDir(), + ) + + if dir := context.GlobalString("version-json"); dir != "" { + generateVersionFiles(updates, dir) + return nil + } + + // In case user wants to do CPU or memory profiles... + if doCPUProfile := context.GlobalBool("cpu-prof"); doCPUProfile { + f, err := os.Create("cpu.pprof") + if err != nil { + log.Fatal("Could not create CPU profile: ", err) + } + if err := pprof.StartCPUProfile(f); err != nil { + log.Fatal("Could not start CPU profile: ", err) + } + defer pprof.StopCPUProfile() + } + + if doMemoryProfile := context.GlobalBool("mem-prof"); doMemoryProfile { + defer makeMemoryProfile() + } + + // Now we initialize all Import/Export parts. + log.Debug("Initializing import/export...") + eventListener := listener.New() + events.SetupEvents(eventListener) + + credentialsStore, credentialsError := credentials.NewStore("import-export") + if credentialsError != nil { + log.Error("Could not get credentials store: ", credentialsError) + } + + cm := pmapi.NewClientManager(cfg.GetAPIConfig()) + + // Different build types have different roundtrippers (e.g. we want to enable + // TLS fingerprint checks in production builds). GetRoundTripper has a different + // implementation depending on whether build flag pmapi_prod is used or not. + cm.SetRoundTripper(cfg.GetRoundTripper(cm, eventListener)) + + importexportInstance := importexport.New(cfg, panicHandler, eventListener, cm, credentialsStore) + + // Decide about frontend mode before initializing rest of import/export. + var frontendMode string + switch { + case context.GlobalBool("cli"): + frontendMode = "cli" + default: + frontendMode = "qt" + } + log.WithField("mode", frontendMode).Debug("Determined frontend mode to use") + + frontend := frontend.NewImportExport(constants.Version, constants.BuildVersion, frontendMode, panicHandler, cfg, eventListener, updates, importexportInstance) + + // Last part is to start everything. + log.Debug("Starting frontend...") + if err := frontend.Loop(credentialsError); err != nil { + log.Error("Frontend failed with error: ", err) + return cli.NewExitError("Frontend error", 2) + } + + if frontend.IsAppRestarting() { + restartApp() + } + + return nil +} + +// generateVersionFiles writes a JSON file with details about current build. +// Those files are used for upgrading the app. +func generateVersionFiles(updates *updates.Updates, dir string) { + log.Info("Generating version files") + for _, goos := range []string{"windows", "darwin", "linux"} { + log.Debug("Generating JSON for ", goos) + if err := updates.CreateJSONAndSign(dir, goos); err != nil { + log.Error(err) + } + } +} + +func makeMemoryProfile() { + name := "./mem.pprof" + f, err := os.Create(name) + if err != nil { + log.Error("Could not create memory profile: ", err) + } + if abs, err := filepath.Abs(name); err == nil { + name = abs + } + log.Info("Writing memory profile to ", name) + runtime.GC() // get up-to-date statistics + if err := pprof.WriteHeapProfile(f); err != nil { + log.Error("Could not write memory profile: ", err) + } + _ = f.Close() +} + +// filterRestartNumberFromArgs removes flag with a number how many restart we already did. +// See restartApp how that number is used. +func filterRestartNumberFromArgs() { + tmp := os.Args[:0] + for i, arg := range os.Args { + if !strings.HasPrefix(arg, "--restart_") { + tmp = append(tmp, arg) + continue + } + var err error + numberOfCrashes, err = strconv.Atoi(os.Args[i][10:]) + if err != nil { + numberOfCrashes = maxAllowedCrashes + } + } + os.Args = tmp +} + +// restartApp starts a new instance in background. +func restartApp() { + if numberOfCrashes >= maxAllowedCrashes { + log.Error("Too many crashes") + return + } + if exeFile, err := os.Executable(); err == nil { + arguments := append(os.Args[1:], fmt.Sprintf("--restart_%d", numberOfCrashes)) + cmd := exec.Command(exeFile, arguments...) //nolint[gosec] + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + if err := cmd.Start(); err != nil { + log.Error("Restart failed: ", err) + } + } +} diff --git a/doc/importexport.md b/doc/importexport.md new file mode 100644 index 00000000..e8449ced --- /dev/null +++ b/doc/importexport.md @@ -0,0 +1,135 @@ +# Import/Export + +## Main blocks + +This is basic overview of the main import/export blocks. + +```mermaid +graph LR + S[ProtonMail server] + U[User] + + subgraph "Import/Export app" + Users + Frontend["Qt / CLI"] + ImportExport + Transfer + + Frontend --> ImportExport + Frontend --> Transfer + ImportExport --> Users + ImportExport --> Transfer + end + + EML --> Transfer + MBOX --> Transfer + IMAP --> Transfer + S --> Transfer + + Transfer --> EML + Transfer --> MBOX + Transfer --> S + + U --> Frontend +``` + +## Code structure + +More detailed graph of main types used in Import/Export app and connection between them. + +```mermaid +graph TD + PM[ProtonMail Server] + EML[EML] + MBOX[MBOX] + IMAP[IMAP] + + subgraph "Import/Export app" + subgraph PkgUsers + subgraph PkgCredentials + CredStore[Store] + Creds[Credentials] + + CredStore --> Creds + end + + US[Users] + U[User] + + US --> U + end + + subgraph PkgFrontend + CLI + Qt + end + + subgraph PkgImportExport + IE[ImportExport] + end + + subgraph PkgTransfer + Transfer + Rules + Progress + + Provider + LocalProvider + EMLProvider + MBOXProvider + IMAPProvider + PMAPIProvider + + Mailbox + Message + + Transfer --> |source|Provider + Transfer --> |target|Provider + Transfer --> Rules + Transfer --> Progress + + Provider --> LocalProvider + Provider --> EMLProvider + Provider --> MBOXProvider + Provider --> IMAPProvider + Provider --> PMAPIProvider + + LocalProvider --> EMLProvider + LocalProvider --> MBOXProvider + + Provider --> Mailbox + Provider --> Message + + end + + subgraph PMAPI + APIM[ClientManager] + APIC[Client] + + APIM --> APIC + end + end + + CLI --> IE + CLI --> Transfer + CLI --> Progress + Qt --> IE + Qt --> Transfer + Qt --> Progress + + U --> CredStore + U --> Creds + + US --> APIM + U --> APIM + + PMAPIProvider --> APIM + EMLProvider --> EML + MBOXProvider --> MBOX + IMAPProvider --> IMAP + + IE --> US + IE --> Transfer + + APIC --> PM +``` diff --git a/doc/index.md b/doc/index.md index 84ee9e52..b6d3a543 100644 --- a/doc/index.md +++ b/doc/index.md @@ -2,8 +2,13 @@ Documentation pages in order to read for a novice: -* [Development cycle](development.md) +## Bridge + * [Bridge code](bridge.md) * [Internal Bridge database](database.md) * [Communication between Bridge, Client and Server](communication.md) * [Encryption](encryption.md) + +## Import/Export + +* [Import/Export code](importexport.md) diff --git a/go.mod b/go.mod index d0e4bfed..167b7846 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,7 @@ require ( github.com/emersion/go-imap-quota v0.0.0-20200423100218-dcfd1b7d2b41 github.com/emersion/go-imap-specialuse v0.0.0-20200722111535-598ff00e4075 github.com/emersion/go-imap-unselect v0.0.0-20171113212723-b985794e5f26 + github.com/emersion/go-mbox v1.0.0 github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe github.com/emersion/go-vcard v0.0.0-20190105225839-8856043f13c5 // indirect diff --git a/go.sum b/go.sum index c4bd7bcc..d58e6f62 100644 --- a/go.sum +++ b/go.sum @@ -61,6 +61,8 @@ github.com/emersion/go-imap-specialuse v0.0.0-20200722111535-598ff00e4075 h1:z8T github.com/emersion/go-imap-specialuse v0.0.0-20200722111535-598ff00e4075/go.mod h1:/nybxhI8kXom8Tw6BrHMl42usALvka6meORflnnYwe4= github.com/emersion/go-imap-unselect v0.0.0-20171113212723-b985794e5f26 h1:FiSb8+XBQQSkcX3ubr+1tAtlRJBYaFmRZqOAweZ9Wy8= github.com/emersion/go-imap-unselect v0.0.0-20171113212723-b985794e5f26/go.mod h1:+gnnZx3Mg3MnCzZrv0eZdp5puxXQUgGT/6N6L7ShKfM= +github.com/emersion/go-mbox v1.0.0 h1:HN6aKbyqmgIfK9fS/gen+NRr2wXLSxZXWfdAIAnzQPc= +github.com/emersion/go-mbox v1.0.0/go.mod h1:Yp9IVuuOYLEuMv4yjgDHvhb5mHOcYH6x92Oas3QqEZI= github.com/emersion/go-message v0.11.1 h1:0C/S4JIXDTSfXB1vpqdimAYyK4+79fgEAMQ0dSL+Kac= github.com/emersion/go-message v0.11.1/go.mod h1:C4jnca5HOTo4bGN9YdqNQM9sITuT3Y0K6bSUw9RklvY= github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b h1:uhWtEWBHgop1rqEk2klKaxPAkVDCXexai6hSuRQ7Nvs= diff --git a/internal/bridge/bridge.go b/internal/bridge/bridge.go index 9fc63d40..3d753afd 100644 --- a/internal/bridge/bridge.go +++ b/internal/bridge/bridge.go @@ -60,7 +60,7 @@ func New( } storeFactory := newStoreFactory(config, panicHandler, clientManager, eventListener) - u := users.New(config, panicHandler, eventListener, clientManager, credStorer, storeFactory) + u := users.New(config, panicHandler, eventListener, clientManager, credStorer, storeFactory, true) b := &Bridge{ Users: u, diff --git a/internal/frontend/autoconfig/applemail.go b/internal/frontend/autoconfig/applemail.go index faa8146a..a2eb02d4 100644 --- a/internal/frontend/autoconfig/applemail.go +++ b/internal/frontend/autoconfig/applemail.go @@ -43,7 +43,7 @@ func (c *appleMail) Name() string { return "Apple Mail" } -func (c *appleMail) Configure(imapPort, smtpPort int, imapSSL, smtpSSL bool, user types.BridgeUser, addressIndex int) error { //nolint[funlen] +func (c *appleMail) Configure(imapPort, smtpPort int, imapSSL, smtpSSL bool, user types.User, addressIndex int) error { //nolint[funlen] var addresses string var displayName string diff --git a/internal/frontend/autoconfig/autoconfig.go b/internal/frontend/autoconfig/autoconfig.go index 81a08ba9..ecfd75f1 100644 --- a/internal/frontend/autoconfig/autoconfig.go +++ b/internal/frontend/autoconfig/autoconfig.go @@ -23,7 +23,7 @@ import "github.com/ProtonMail/proton-bridge/internal/frontend/types" type AutoConfig interface { Name() string - Configure(imapPort int, smtpPort int, imapSSl, smtpSSL bool, user types.BridgeUser, addressIndex int) error + Configure(imapPort int, smtpPort int, imapSSl, smtpSSL bool, user types.User, addressIndex int) error } var available []AutoConfig //nolint[gochecknoglobals] diff --git a/internal/frontend/cli-ie/account_utils.go b/internal/frontend/cli-ie/account_utils.go new file mode 100644 index 00000000..d016ce26 --- /dev/null +++ b/internal/frontend/cli-ie/account_utils.go @@ -0,0 +1,100 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package cli + +import ( + "fmt" + "strconv" + "strings" + + "github.com/ProtonMail/proton-bridge/internal/frontend/types" + "github.com/abiosoft/ishell" +) + +// completeUsernames is a helper to complete usernames as the user types. +func (f *frontendCLI) completeUsernames(args []string) (usernames []string) { + if len(args) > 1 { + return + } + arg := "" + if len(args) == 1 { + arg = args[0] + } + for _, user := range f.ie.GetUsers() { + if strings.HasPrefix(strings.ToLower(user.Username()), strings.ToLower(arg)) { + usernames = append(usernames, user.Username()) + } + } + return +} + +// noAccountWrapper is a decorator for functions which need any account to be properly functional. +func (f *frontendCLI) noAccountWrapper(callback func(*ishell.Context)) func(*ishell.Context) { + return func(c *ishell.Context) { + users := f.ie.GetUsers() + if len(users) == 0 { + f.Println("No active accounts. Please add account to continue.") + } else { + callback(c) + } + } +} + +func (f *frontendCLI) askUserByIndexOrName(c *ishell.Context) types.User { + user := f.getUserByIndexOrName("") + if user != nil { + return user + } + + numberOfAccounts := len(f.ie.GetUsers()) + indexRange := fmt.Sprintf("number between 0 and %d", numberOfAccounts-1) + if len(c.Args) == 0 { + f.Printf("Please choose %s or username.\n", indexRange) + return nil + } + arg := c.Args[0] + user = f.getUserByIndexOrName(arg) + if user == nil { + f.Printf("Wrong input '%s'. Choose %s or username.\n", bold(arg), indexRange) + return nil + } + return user +} + +func (f *frontendCLI) getUserByIndexOrName(arg string) types.User { + users := f.ie.GetUsers() + numberOfAccounts := len(users) + if numberOfAccounts == 0 { + return nil + } + if numberOfAccounts == 1 { + return users[0] + } + if index, err := strconv.Atoi(arg); err == nil { + if index < 0 || index >= numberOfAccounts { + return nil + } + return users[index] + } + for _, user := range users { + if user.Username() == arg { + return user + } + } + return nil +} diff --git a/internal/frontend/cli-ie/accounts.go b/internal/frontend/cli-ie/accounts.go new file mode 100644 index 00000000..ae3764f3 --- /dev/null +++ b/internal/frontend/cli-ie/accounts.go @@ -0,0 +1,153 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package cli + +import ( + "strings" + + "github.com/abiosoft/ishell" +) + +func (f *frontendCLI) listAccounts(c *ishell.Context) { + spacing := "%-2d: %-20s (%-15s, %-15s)\n" + f.Printf(bold(strings.Replace(spacing, "d", "s", -1)), "#", "account", "status", "address mode") + for idx, user := range f.ie.GetUsers() { + connected := "disconnected" + if user.IsConnected() { + connected = "connected" + } + mode := "split" + if user.IsCombinedAddressMode() { + mode = "combined" + } + f.Printf(spacing, idx, user.Username(), connected, mode) + } + f.Println() +} + +func (f *frontendCLI) loginAccount(c *ishell.Context) { // nolint[funlen] + f.ShowPrompt(false) + defer f.ShowPrompt(true) + + loginName := "" + if len(c.Args) > 0 { + user := f.getUserByIndexOrName(c.Args[0]) + if user != nil { + loginName = user.GetPrimaryAddress() + } + } + + if loginName == "" { + loginName = f.readStringInAttempts("Username", c.ReadLine, isNotEmpty) + if loginName == "" { + return + } + } else { + f.Println("Username:", loginName) + } + + password := f.readStringInAttempts("Password", c.ReadPassword, isNotEmpty) + if password == "" { + return + } + + f.Println("Authenticating ... ") + client, auth, err := f.ie.Login(loginName, password) + if err != nil { + f.processAPIError(err) + return + } + + if auth.HasTwoFactor() { + twoFactor := f.readStringInAttempts("Two factor code", c.ReadLine, isNotEmpty) + if twoFactor == "" { + return + } + + _, err = client.Auth2FA(twoFactor, auth) + if err != nil { + f.processAPIError(err) + return + } + } + + mailboxPassword := password + if auth.HasMailboxPassword() { + mailboxPassword = f.readStringInAttempts("Mailbox password", c.ReadPassword, isNotEmpty) + } + if mailboxPassword == "" { + return + } + + f.Println("Adding account ...") + user, err := f.ie.FinishLogin(client, auth, mailboxPassword) + if err != nil { + log.WithField("username", loginName).WithError(err).Error("Login was unsuccessful") + f.Println("Adding account was unsuccessful:", err) + return + } + + f.Printf("Account %s was added successfully.\n", bold(user.Username())) +} + +func (f *frontendCLI) logoutAccount(c *ishell.Context) { + f.ShowPrompt(false) + defer f.ShowPrompt(true) + + user := f.askUserByIndexOrName(c) + if user == nil { + return + } + if f.yesNoQuestion("Are you sure you want to logout account " + bold(user.Username())) { + if err := user.Logout(); err != nil { + f.printAndLogError("Logging out failed: ", err) + } + } +} + +func (f *frontendCLI) deleteAccount(c *ishell.Context) { + f.ShowPrompt(false) + defer f.ShowPrompt(true) + + user := f.askUserByIndexOrName(c) + if user == nil { + return + } + if f.yesNoQuestion("Are you sure you want to " + bold("remove account "+user.Username())) { + clearCache := f.yesNoQuestion("Do you want to remove cache for this account") + if err := f.ie.DeleteUser(user.ID(), clearCache); err != nil { + f.printAndLogError("Cannot delete account: ", err) + return + } + } +} + +func (f *frontendCLI) deleteAccounts(c *ishell.Context) { + f.ShowPrompt(false) + defer f.ShowPrompt(true) + + if !f.yesNoQuestion("Do you really want remove all accounts") { + return + } + for _, user := range f.ie.GetUsers() { + if err := f.ie.DeleteUser(user.ID(), false); err != nil { + f.printAndLogError("Cannot delete account ", user.Username(), ": ", err) + } + } + c.Println("Keychain cleared") +} diff --git a/internal/frontend/cli-ie/frontend.go b/internal/frontend/cli-ie/frontend.go new file mode 100644 index 00000000..7bb11586 --- /dev/null +++ b/internal/frontend/cli-ie/frontend.go @@ -0,0 +1,233 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Package cli provides CLI interface of the Bridge. +package cli + +import ( + "github.com/ProtonMail/proton-bridge/internal/events" + "github.com/ProtonMail/proton-bridge/internal/frontend/types" + "github.com/ProtonMail/proton-bridge/pkg/config" + "github.com/ProtonMail/proton-bridge/pkg/listener" + + "github.com/abiosoft/ishell" + "github.com/sirupsen/logrus" +) + +var ( + log = logrus.WithField("pkg", "frontend/cli-ie") //nolint[gochecknoglobals] +) + +type frontendCLI struct { + *ishell.Shell + + config *config.Config + eventListener listener.Listener + updates types.Updater + ie types.ImportExporter + + appRestart bool +} + +// New returns a new CLI frontend configured with the given options. +func New( //nolint[funlen] + panicHandler types.PanicHandler, + config *config.Config, + eventListener listener.Listener, + updates types.Updater, + ie types.ImportExporter, +) *frontendCLI { //nolint[golint] + fe := &frontendCLI{ + Shell: ishell.New(), + + config: config, + eventListener: eventListener, + updates: updates, + ie: ie, + + appRestart: false, + } + + // Clear commands. + clearCmd := &ishell.Cmd{Name: "clear", + Help: "remove stored accounts and preferences. (alias: cl)", + Aliases: []string{"cl"}, + } + clearCmd.AddCmd(&ishell.Cmd{Name: "accounts", + Help: "remove all accounts from keychain. (aliases: k, keychain)", + Aliases: []string{"a", "k", "keychain"}, + Func: fe.deleteAccounts, + }) + fe.AddCmd(clearCmd) + + // Check commands. + checkCmd := &ishell.Cmd{Name: "check", Help: "check internet connection or new version."} + checkCmd.AddCmd(&ishell.Cmd{Name: "updates", + Help: "check for Import/Export updates. (aliases: u, v, version)", + Aliases: []string{"u", "version", "v"}, + Func: fe.checkUpdates, + }) + checkCmd.AddCmd(&ishell.Cmd{Name: "internet", + Help: "check internet connection. (aliases: i, conn, connection)", + Aliases: []string{"i", "con", "connection"}, + Func: fe.checkInternetConnection, + }) + fe.AddCmd(checkCmd) + + // Print info commands. + fe.AddCmd(&ishell.Cmd{Name: "log-dir", + Help: "print path to directory with logs. (aliases: log, logs)", + Aliases: []string{"log", "logs"}, + Func: fe.printLogDir, + }) + fe.AddCmd(&ishell.Cmd{Name: "manual", + Help: "print URL with instructions. (alias: man)", + Aliases: []string{"man"}, + Func: fe.printManual, + }) + fe.AddCmd(&ishell.Cmd{Name: "release-notes", + Help: "print release notes. (aliases: notes, fixed-bugs, bugs, ver, version)", + Aliases: []string{"notes", "fixed-bugs", "bugs", "ver", "version"}, + Func: fe.printLocalReleaseNotes, + }) + fe.AddCmd(&ishell.Cmd{Name: "credits", + Help: "print used resources.", + Func: fe.printCredits, + }) + + // Account commands. + fe.AddCmd(&ishell.Cmd{Name: "list", + Help: "print the list of accounts. (aliases: l, ls)", + Func: fe.noAccountWrapper(fe.listAccounts), + Aliases: []string{"l", "ls"}, + }) + fe.AddCmd(&ishell.Cmd{Name: "login", + Help: "login procedure to add or connect account. Optionally use index or account as parameter. (aliases: a, add, con, connect)", + Func: fe.loginAccount, + Aliases: []string{"add", "a", "con", "connect"}, + Completer: fe.completeUsernames, + }) + fe.AddCmd(&ishell.Cmd{Name: "logout", + Help: "disconnect the account. Use index or account name as parameter. (aliases: d, disconnect)", + Func: fe.noAccountWrapper(fe.logoutAccount), + Aliases: []string{"d", "disconnect"}, + Completer: fe.completeUsernames, + }) + fe.AddCmd(&ishell.Cmd{Name: "delete", + Help: "remove the account from keychain. Use index or account name as parameter. (aliases: del, rm, remove)", + Func: fe.noAccountWrapper(fe.deleteAccount), + Aliases: []string{"del", "rm", "remove"}, + Completer: fe.completeUsernames, + }) + + // Import/Export commands. + importCmd := &ishell.Cmd{Name: "import", + Help: "import messages. (alias: imp)", + Aliases: []string{"imp"}, + } + importCmd.AddCmd(&ishell.Cmd{Name: "local", + Help: "import local messages. (aliases: loc)", + Func: fe.noAccountWrapper(fe.importLocalMessages), + Aliases: []string{"loc"}, + }) + importCmd.AddCmd(&ishell.Cmd{Name: "remote", + Help: "import remote messages. (aliases: rem)", + Func: fe.noAccountWrapper(fe.importRemoteMessages), + Aliases: []string{"rem"}, + }) + fe.AddCmd(importCmd) + + exportCmd := &ishell.Cmd{Name: "export", + Help: "export messages. (alias: exp)", + Aliases: []string{"exp"}, + } + exportCmd.AddCmd(&ishell.Cmd{Name: "eml", + Help: "export messages to eml files.", + Func: fe.noAccountWrapper(fe.exportMessagesToEML), + }) + exportCmd.AddCmd(&ishell.Cmd{Name: "mbox", + Help: "export messages to mbox files.", + Func: fe.noAccountWrapper(fe.exportMessagesToMBOX), + }) + fe.AddCmd(exportCmd) + + // System commands. + fe.AddCmd(&ishell.Cmd{Name: "restart", + Help: "restart the import/export.", + Func: fe.restart, + }) + + go func() { + defer panicHandler.HandlePanic() + fe.watchEvents() + }() + fe.eventListener.RetryEmit(events.TLSCertIssue) + fe.eventListener.RetryEmit(events.ErrorEvent) + return fe +} + +func (f *frontendCLI) watchEvents() { + errorCh := f.getEventChannel(events.ErrorEvent) + internetOffCh := f.getEventChannel(events.InternetOffEvent) + internetOnCh := f.getEventChannel(events.InternetOnEvent) + addressChangedLogoutCh := f.getEventChannel(events.AddressChangedLogoutEvent) + logoutCh := f.getEventChannel(events.LogoutEvent) + certIssue := f.getEventChannel(events.TLSCertIssue) + for { + select { + case errorDetails := <-errorCh: + f.Println("Import/Export failed:", errorDetails) + case <-internetOffCh: + f.notifyInternetOff() + case <-internetOnCh: + f.notifyInternetOn() + case address := <-addressChangedLogoutCh: + f.notifyLogout(address) + case userID := <-logoutCh: + user, err := f.ie.GetUser(userID) + if err != nil { + return + } + f.notifyLogout(user.Username()) + case <-certIssue: + f.notifyCertIssue() + } + } +} + +func (f *frontendCLI) getEventChannel(event string) <-chan string { + ch := make(chan string) + f.eventListener.Add(event, ch) + return ch +} + +// IsAppRestarting returns whether the app is currently set to restart. +func (f *frontendCLI) IsAppRestarting() bool { + return f.appRestart +} + +// Loop starts the frontend loop with an interactive shell. +func (f *frontendCLI) Loop(credentialsError error) error { + if credentialsError != nil { + f.notifyCredentialsError() + return credentialsError + } + + f.Print(`Welcome to ProtonMail Import/Export interactive shell`) + f.Run() + return nil +} diff --git a/internal/frontend/cli-ie/importexport.go b/internal/frontend/cli-ie/importexport.go new file mode 100644 index 00000000..05db81bf --- /dev/null +++ b/internal/frontend/cli-ie/importexport.go @@ -0,0 +1,222 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package cli + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/ProtonMail/proton-bridge/internal/frontend/types" + "github.com/ProtonMail/proton-bridge/internal/transfer" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/abiosoft/ishell" +) + +func (f *frontendCLI) importLocalMessages(c *ishell.Context) { + f.ShowPrompt(false) + defer f.ShowPrompt(true) + + user, path := f.getUserAndPath(c, false) + if user == nil || path == "" { + return + } + + t, err := f.ie.GetLocalImporter(user.GetPrimaryAddress(), path) + f.transfer(t, err, false, true) +} + +func (f *frontendCLI) importRemoteMessages(c *ishell.Context) { + f.ShowPrompt(false) + defer f.ShowPrompt(true) + + user := f.askUserByIndexOrName(c) + if user == nil { + return + } + + username := f.readStringInAttempts("IMAP username", c.ReadLine, isNotEmpty) + if username == "" { + return + } + password := f.readStringInAttempts("IMAP password", c.ReadPassword, isNotEmpty) + if password == "" { + return + } + host := f.readStringInAttempts("IMAP host", c.ReadLine, isNotEmpty) + if host == "" { + return + } + port := f.readStringInAttempts("IMAP port", c.ReadLine, isNotEmpty) + if port == "" { + return + } + + t, err := f.ie.GetRemoteImporter(user.GetPrimaryAddress(), username, password, host, port) + f.transfer(t, err, false, true) +} + +func (f *frontendCLI) exportMessagesToEML(c *ishell.Context) { + f.ShowPrompt(false) + defer f.ShowPrompt(true) + + user, path := f.getUserAndPath(c, true) + if user == nil || path == "" { + return + } + + t, err := f.ie.GetEMLExporter(user.GetPrimaryAddress(), path) + f.transfer(t, err, true, false) +} + +func (f *frontendCLI) exportMessagesToMBOX(c *ishell.Context) { + f.ShowPrompt(false) + defer f.ShowPrompt(true) + + user, path := f.getUserAndPath(c, true) + if user == nil || path == "" { + return + } + + t, err := f.ie.GetMBOXExporter(user.GetPrimaryAddress(), path) + f.transfer(t, err, true, false) +} + +func (f *frontendCLI) getUserAndPath(c *ishell.Context, createPath bool) (types.User, string) { + user := f.askUserByIndexOrName(c) + if user == nil { + return nil, "" + } + + path := f.readStringInAttempts("Path of EML and MBOX files", c.ReadLine, isNotEmpty) + if path == "" { + return nil, "" + } + + if createPath { + _ = os.Mkdir(path, os.ModePerm) + } + + return user, path +} + +func (f *frontendCLI) transfer(t *transfer.Transfer, err error, askSkipEncrypted bool, askGlobalMailbox bool) { + if err != nil { + f.printAndLogError("Failed to init transferrer: ", err) + return + } + + if askSkipEncrypted { + skipEncryptedMessages := f.yesNoQuestion("Skip encrypted messages") + t.SetSkipEncryptedMessages(skipEncryptedMessages) + } + + if !f.setTransferRules(t) { + return + } + + if askGlobalMailbox { + if err := f.setTransferGlobalMailbox(t); err != nil { + f.printAndLogError("Failed to create global mailbox: ", err) + return + } + } + + progress := t.Start() + for range progress.GetUpdateChannel() { + f.printTransferProgress(progress) + } + f.printTransferResult(progress) +} + +func (f *frontendCLI) setTransferGlobalMailbox(t *transfer.Transfer) error { + labelName := fmt.Sprintf("Imported %s", time.Now().Format("Jan-02-2006 15:04")) + + useGlobalLabel := f.yesNoQuestion("Use global label " + labelName) + if !useGlobalLabel { + return nil + } + + globalMailbox, err := t.CreateTargetMailbox(transfer.Mailbox{ + Name: labelName, + Color: pmapi.LabelColors[0], + IsExclusive: false, + }) + if err != nil { + return err + } + + t.SetGlobalMailbox(&globalMailbox) + return nil +} + +func (f *frontendCLI) setTransferRules(t *transfer.Transfer) bool { + f.Println("Rules:") + for _, rule := range t.GetRules() { + if !rule.Active { + continue + } + targets := strings.Join(rule.TargetMailboxNames(), ", ") + if rule.HasTimeLimit() { + f.Printf(" %-30s → %s (%s - %s)\n", rule.SourceMailbox.Name, targets, rule.FromDate(), rule.ToDate()) + } else { + f.Printf(" %-30s → %s\n", rule.SourceMailbox.Name, targets) + } + } + + return f.yesNoQuestion("Proceed") +} + +func (f *frontendCLI) printTransferProgress(progress *transfer.Progress) { + failed, imported, exported, added, total := progress.GetCounts() + f.Println(fmt.Sprintf("Progress update: %d (%d / %d) / %d, failed: %d", imported, exported, added, total, failed)) + + if progress.IsPaused() { + f.Printf("Transfer is paused bacause %s", progress.PauseReason()) + if !f.yesNoQuestion("Continue (y) or stop (n)") { + progress.Stop() + } + } +} + +func (f *frontendCLI) printTransferResult(progress *transfer.Progress) { + err := progress.GetFatalError() + if err != nil { + f.Println("Transfer failed: " + err.Error()) + return + } + + statuses := progress.GetFailedMessages() + if len(statuses) == 0 { + f.Println("Transfer finished!") + return + } + + f.Println("Transfer finished with errors:") + for _, messageStatus := range statuses { + f.Printf( + " %-17s | %-30s | %-30s\n %s: %s\n", + messageStatus.Time.Format("Jan 02 2006 15:04"), + messageStatus.From, + messageStatus.Subject, + messageStatus.SourceID, + messageStatus.GetErrorMessage(), + ) + } +} diff --git a/internal/frontend/cli-ie/system.go b/internal/frontend/cli-ie/system.go new file mode 100644 index 00000000..2df6574e --- /dev/null +++ b/internal/frontend/cli-ie/system.go @@ -0,0 +1,50 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package cli + +import ( + "github.com/abiosoft/ishell" +) + +var ( + currentPort = "" //nolint[gochecknoglobals] +) + +func (f *frontendCLI) restart(c *ishell.Context) { + if f.yesNoQuestion("Are you sure you want to restart the Import/Export") { + f.Println("Restarting Import/Export...") + f.appRestart = true + f.Stop() + } +} + +func (f *frontendCLI) checkInternetConnection(c *ishell.Context) { + if f.ie.CheckConnection() == nil { + f.Println("Internet connection is available.") + } else { + f.Println("Can not contact the server, please check you internet connection.") + } +} + +func (f *frontendCLI) printLogDir(c *ishell.Context) { + f.Println("Log files are stored in\n\n ", f.config.GetLogDir()) +} + +func (f *frontendCLI) printManual(c *ishell.Context) { + f.Println("More instructions about the Import/Export can be found at\n\n https://protonmail.com/support/categories/import-export/") +} diff --git a/internal/frontend/cli-ie/updates.go b/internal/frontend/cli-ie/updates.go new file mode 100644 index 00000000..b30da468 --- /dev/null +++ b/internal/frontend/cli-ie/updates.go @@ -0,0 +1,65 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package cli + +import ( + "strings" + + "github.com/ProtonMail/proton-bridge/internal/bridge" + "github.com/ProtonMail/proton-bridge/pkg/updates" + "github.com/abiosoft/ishell" +) + +func (f *frontendCLI) checkUpdates(c *ishell.Context) { + isUpToDate, latestVersionInfo, err := f.updates.CheckIsUpToDate() + if err != nil { + f.printAndLogError("Cannot retrieve version info: ", err) + f.checkInternetConnection(c) + return + } + if isUpToDate { + f.Println("Your version is up to date.") + } else { + f.notifyNeedUpgrade() + f.Println("") + f.printReleaseNotes(latestVersionInfo) + } +} + +func (f *frontendCLI) printLocalReleaseNotes(c *ishell.Context) { + localVersion := f.updates.GetLocalVersion() + f.printReleaseNotes(localVersion) +} + +func (f *frontendCLI) printReleaseNotes(versionInfo updates.VersionInfo) { + f.Println(bold("ProtonMail Bridge "+versionInfo.Version), "\n") + if versionInfo.ReleaseNotes != "" { + f.Println(bold("Release Notes")) + f.Println(versionInfo.ReleaseNotes) + } + if versionInfo.ReleaseFixedBugs != "" { + f.Println(bold("Fixed bugs")) + f.Println(versionInfo.ReleaseFixedBugs) + } +} + +func (f *frontendCLI) printCredits(c *ishell.Context) { + for _, pkg := range strings.Split(bridge.Credits, ";") { + f.Println(pkg) + } +} diff --git a/internal/frontend/cli-ie/utils.go b/internal/frontend/cli-ie/utils.go new file mode 100644 index 00000000..f5a97000 --- /dev/null +++ b/internal/frontend/cli-ie/utils.go @@ -0,0 +1,123 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package cli + +import ( + "strings" + + pmapi "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/fatih/color" +) + +const ( + maxInputRepeat = 2 +) + +var ( + bold = color.New(color.Bold).SprintFunc() //nolint[gochecknoglobals] +) + +func isNotEmpty(val string) bool { + return val != "" +} + +func (f *frontendCLI) yesNoQuestion(question string) bool { + f.Print(question, "? yes/"+bold("no")+": ") + yes := "yes" + answer := strings.ToLower(f.ReadLine()) + for i := 0; i < len(answer); i++ { + if i >= len(yes) || answer[i] != yes[i] { + return false // Everything else is false. + } + } + return len(answer) > 0 // Empty is false. +} + +func (f *frontendCLI) readStringInAttempts(title string, readFunc func() string, isOK func(string) bool) (value string) { + f.Printf("%s: ", title) + value = readFunc() + title = strings.ToLower(string(title[0])) + title[1:] + for i := 0; !isOK(value); i++ { + if i >= maxInputRepeat { + f.Println("Too many attempts") + return "" + } + f.Printf("Please fill %s: ", title) + value = readFunc() + } + return +} + +func (f *frontendCLI) printAndLogError(args ...interface{}) { + log.Error(args...) + f.Println(args...) +} + +func (f *frontendCLI) processAPIError(err error) { + log.Warn("API error: ", err) + switch err { + case pmapi.ErrAPINotReachable: + f.notifyInternetOff() + case pmapi.ErrUpgradeApplication: + f.notifyNeedUpgrade() + default: + f.Println("Server error:", err.Error()) + } +} + +func (f *frontendCLI) notifyInternetOff() { + f.Println("Internet connection is not available.") +} + +func (f *frontendCLI) notifyInternetOn() { + f.Println("Internet connection is available again.") +} + +func (f *frontendCLI) notifyLogout(address string) { + f.Printf("Account %s is disconnected. Login to continue using this account with email client.", address) +} + +func (f *frontendCLI) notifyNeedUpgrade() { + f.Println("Please download and install the newest version of application from", f.updates.GetDownloadLink()) +} + +func (f *frontendCLI) notifyCredentialsError() { + // Print in 80-column width. + f.Println("ProtonMail Bridge is not able to detect a supported password manager") + f.Println("(pass, gnome-keyring). Please install and set up a supported password manager") + f.Println("and restart the application.") +} + +func (f *frontendCLI) notifyCertIssue() { + // Print in 80-column width. + f.Println(`Connection security error: Your network connection to Proton services may +be insecure. + +Description: +ProtonMail Bridge was not able to establish a secure connection to Proton +servers due to a TLS certificate error. This means your connection may +potentially be insecure and susceptible to monitoring by third parties. + +Recommendation: +* If you trust your network operator, you can continue to use ProtonMail + as usual. +* If you don't trust your network operator, reconnect to ProtonMail over a VPN + (such as ProtonVPN) which encrypts your Internet connection, or use + a different network to access ProtonMail. +`) +} diff --git a/internal/frontend/cli/account_utils.go b/internal/frontend/cli/account_utils.go index c2fdaec5..68bfd02d 100644 --- a/internal/frontend/cli/account_utils.go +++ b/internal/frontend/cli/account_utils.go @@ -55,7 +55,7 @@ func (f *frontendCLI) noAccountWrapper(callback func(*ishell.Context)) func(*ish } } -func (f *frontendCLI) askUserByIndexOrName(c *ishell.Context) types.BridgeUser { +func (f *frontendCLI) askUserByIndexOrName(c *ishell.Context) types.User { user := f.getUserByIndexOrName("") if user != nil { return user @@ -76,7 +76,7 @@ func (f *frontendCLI) askUserByIndexOrName(c *ishell.Context) types.BridgeUser { return user } -func (f *frontendCLI) getUserByIndexOrName(arg string) types.BridgeUser { +func (f *frontendCLI) getUserByIndexOrName(arg string) types.User { users := f.bridge.GetUsers() numberOfAccounts := len(users) if numberOfAccounts == 0 { diff --git a/internal/frontend/cli/accounts.go b/internal/frontend/cli/accounts.go index 213f11ec..a2f815a0 100644 --- a/internal/frontend/cli/accounts.go +++ b/internal/frontend/cli/accounts.go @@ -63,7 +63,7 @@ func (f *frontendCLI) showAccountInfo(c *ishell.Context) { } } -func (f *frontendCLI) showAccountAddressInfo(user types.BridgeUser, address string) { +func (f *frontendCLI) showAccountAddressInfo(user types.User, address string) { smtpSecurity := "STARTTLS" if f.preferences.GetBool(preferences.SMTPSSLKey) { smtpSecurity = "SSL" diff --git a/internal/frontend/cli/system.go b/internal/frontend/cli/system.go index aaa3cef8..a32dcc9c 100644 --- a/internal/frontend/cli/system.go +++ b/internal/frontend/cli/system.go @@ -43,7 +43,7 @@ func (f *frontendCLI) checkInternetConnection(c *ishell.Context) { if f.bridge.CheckConnection() == nil { f.Println("Internet connection is available.") } else { - f.Println("Can not contact server please check you internet connection.") + f.Println("Can not contact the server, please check you internet connection.") } } diff --git a/internal/frontend/cli/updates.go b/internal/frontend/cli/updates.go index c72b0b76..55d35e16 100644 --- a/internal/frontend/cli/updates.go +++ b/internal/frontend/cli/updates.go @@ -26,7 +26,7 @@ import ( ) func (f *frontendCLI) checkUpdates(c *ishell.Context) { - isUpToDate, latestVersionInfo, err := f.updates.CheckIsBridgeUpToDate() + isUpToDate, latestVersionInfo, err := f.updates.CheckIsUpToDate() if err != nil { f.printAndLogError("Cannot retrieve version info: ", err) f.checkInternetConnection(c) @@ -47,7 +47,7 @@ func (f *frontendCLI) printLocalReleaseNotes(c *ishell.Context) { } func (f *frontendCLI) printReleaseNotes(versionInfo updates.VersionInfo) { - f.Println(bold("ProtonMail Bridge "+versionInfo.Version), "\n") + f.Println(bold("ProtonMail Import/Export "+versionInfo.Version), "\n") if versionInfo.ReleaseNotes != "" { f.Println(bold("Release Notes")) f.Println(versionInfo.ReleaseNotes) diff --git a/internal/frontend/frontend.go b/internal/frontend/frontend.go index 6010faef..33f660de 100644 --- a/internal/frontend/frontend.go +++ b/internal/frontend/frontend.go @@ -22,8 +22,10 @@ import ( "github.com/0xAX/notificator" "github.com/ProtonMail/proton-bridge/internal/bridge" "github.com/ProtonMail/proton-bridge/internal/frontend/cli" + cliie "github.com/ProtonMail/proton-bridge/internal/frontend/cli-ie" "github.com/ProtonMail/proton-bridge/internal/frontend/qt" "github.com/ProtonMail/proton-bridge/internal/frontend/types" + "github.com/ProtonMail/proton-bridge/internal/importexport" "github.com/ProtonMail/proton-bridge/pkg/config" "github.com/ProtonMail/proton-bridge/pkg/listener" "github.com/sirupsen/logrus" @@ -86,3 +88,37 @@ func new( return qt.New(version, buildVersion, showWindowOnStart, panicHandler, config, preferences, eventListener, updates, bridge, noEncConfirmator) } } + +// NewImportExport returns initialized frontend based on `frontendType`, which can be `cli` or `qt`. +func NewImportExport( + version, + buildVersion, + frontendType string, + panicHandler types.PanicHandler, + config *config.Config, + eventListener listener.Listener, + updates types.Updater, + ie *importexport.ImportExport, +) Frontend { + ieWrap := types.NewImportExportWrap(ie) + return newImportExport(version, buildVersion, frontendType, panicHandler, config, eventListener, updates, ieWrap) +} + +func newImportExport( + version, + buildVersion, + frontendType string, + panicHandler types.PanicHandler, + config *config.Config, + eventListener listener.Listener, + updates types.Updater, + ie types.ImportExporter, +) Frontend { + switch frontendType { + case "cli": + return cliie.New(panicHandler, config, eventListener, updates, ie) + default: + return cliie.New(panicHandler, config, eventListener, updates, ie) + //return qt.New(panicHandler, config, eventListener, updates, ie) + } +} diff --git a/internal/frontend/qt/frontend.go b/internal/frontend/qt/frontend.go index f4d84d91..57b3a3c0 100644 --- a/internal/frontend/qt/frontend.go +++ b/internal/frontend/qt/frontend.go @@ -410,7 +410,7 @@ func (s *FrontendQt) isNewVersionAvailable(showMessage bool) { go func() { defer s.panicHandler.HandlePanic() defer s.Qml.ProcessFinished() - isUpToDate, latestVersionInfo, err := s.updates.CheckIsBridgeUpToDate() + isUpToDate, latestVersionInfo, err := s.updates.CheckIsUpToDate() if err != nil { log.Warn("Can not retrieve version info: ", err) s.checkInternet() diff --git a/internal/frontend/types/types.go b/internal/frontend/types/types.go index 46558bc9..ee180249 100644 --- a/internal/frontend/types/types.go +++ b/internal/frontend/types/types.go @@ -20,6 +20,8 @@ package types import ( "github.com/ProtonMail/proton-bridge/internal/bridge" + "github.com/ProtonMail/proton-bridge/internal/importexport" + "github.com/ProtonMail/proton-bridge/internal/transfer" "github.com/ProtonMail/proton-bridge/pkg/pmapi" "github.com/ProtonMail/proton-bridge/pkg/updates" ) @@ -31,7 +33,7 @@ type PanicHandler interface { // Updater is an interface for handling Bridge upgrades. type Updater interface { - CheckIsBridgeUpToDate() (isUpToDate bool, latestVersion updates.VersionInfo, err error) + CheckIsUpToDate() (isUpToDate bool, latestVersion updates.VersionInfo, err error) GetDownloadLink() string GetLocalVersion() updates.VersionInfo StartUpgrade(currentStatus chan<- updates.Progress) @@ -41,24 +43,19 @@ type NoEncConfirmator interface { ConfirmNoEncryption(string, bool) } -// Bridger is an interface of bridge needed by frontend. -type Bridger interface { - GetCurrentClient() string - SetCurrentOS(os string) +// UserManager is an interface of users needed by frontend. +type UserManager interface { Login(username, password string) (pmapi.Client, *pmapi.Auth, error) - FinishLogin(client pmapi.Client, auth *pmapi.Auth, mailboxPassword string) (BridgeUser, error) - GetUsers() []BridgeUser - GetUser(query string) (BridgeUser, error) + FinishLogin(client pmapi.Client, auth *pmapi.Auth, mailboxPassword string) (User, error) + GetUsers() []User + GetUser(query string) (User, error) DeleteUser(userID string, clearCache bool) error - ReportBug(osType, osVersion, description, accountName, address, emailClient string) error ClearData() error - AllowProxy() - DisallowProxy() CheckConnection() error } -// BridgeUser is an interface of user needed by frontend. -type BridgeUser interface { +// User is an interface of user needed by frontend. +type User interface { ID() string Username() string IsConnected() bool @@ -70,6 +67,17 @@ type BridgeUser interface { Logout() error } +// Bridger is an interface of bridge needed by frontend. +type Bridger interface { + UserManager + + GetCurrentClient() string + SetCurrentOS(os string) + ReportBug(osType, osVersion, description, accountName, address, emailClient string) error + AllowProxy() + DisallowProxy() +} + type bridgeWrap struct { *bridge.Bridge } @@ -81,17 +89,55 @@ func NewBridgeWrap(bridge *bridge.Bridge) *bridgeWrap { //nolint[golint] return &bridgeWrap{Bridge: bridge} } -func (b *bridgeWrap) FinishLogin(client pmapi.Client, auth *pmapi.Auth, mailboxPassword string) (BridgeUser, error) { +func (b *bridgeWrap) FinishLogin(client pmapi.Client, auth *pmapi.Auth, mailboxPassword string) (User, error) { return b.Bridge.FinishLogin(client, auth, mailboxPassword) } -func (b *bridgeWrap) GetUsers() (users []BridgeUser) { +func (b *bridgeWrap) GetUsers() (users []User) { for _, user := range b.Bridge.GetUsers() { users = append(users, user) } return } -func (b *bridgeWrap) GetUser(query string) (BridgeUser, error) { +func (b *bridgeWrap) GetUser(query string) (User, error) { return b.Bridge.GetUser(query) } + +// ImportExporter is an interface of import/export needed by frontend. +type ImportExporter interface { + UserManager + + GetLocalImporter(string, string) (*transfer.Transfer, error) + GetRemoteImporter(string, string, string, string, string) (*transfer.Transfer, error) + GetEMLExporter(string, string) (*transfer.Transfer, error) + GetMBOXExporter(string, string) (*transfer.Transfer, error) +} + +type importExportWrap struct { + *importexport.ImportExport +} + +// NewImportExportWrap wraps import/export struct into local importExportWrap +// to implement local interface. +// The problem is that Import/Export returns the importexport package's User +// type. Every method which returns User therefore has to be overridden to +// fulfill the interface. +func NewImportExportWrap(ie *importexport.ImportExport) *importExportWrap { //nolint[golint] + return &importExportWrap{ImportExport: ie} +} + +func (b *importExportWrap) FinishLogin(client pmapi.Client, auth *pmapi.Auth, mailboxPassword string) (User, error) { + return b.ImportExport.FinishLogin(client, auth, mailboxPassword) +} + +func (b *importExportWrap) GetUsers() (users []User) { + for _, user := range b.ImportExport.GetUsers() { + users = append(users, user) + } + return +} + +func (b *importExportWrap) GetUser(query string) (User, error) { + return b.ImportExport.GetUser(query) +} diff --git a/internal/imap/mailbox_message.go b/internal/imap/mailbox_message.go index 85e7f0d5..730f7079 100644 --- a/internal/imap/mailbox_message.go +++ b/internal/imap/mailbox_message.go @@ -19,17 +19,14 @@ package imap import ( "bytes" - "encoding/base64" "fmt" "io" - "io/ioutil" "mime/multipart" "net/mail" "net/textproto" "regexp" "sort" "strings" - "text/template" "time" "github.com/ProtonMail/gopenpgp/v2/crypto" @@ -39,7 +36,6 @@ import ( "github.com/ProtonMail/proton-bridge/pkg/parallel" "github.com/ProtonMail/proton-bridge/pkg/pmapi" "github.com/emersion/go-imap" - "github.com/emersion/go-textwrapper" "github.com/hashicorp/go-multierror" enmime "github.com/jhillyerd/enmime" "github.com/pkg/errors" @@ -185,74 +181,8 @@ func (im *imapMailbox) CreateMessage(flags []string, date time.Time, body imap.L } func (im *imapMailbox) importMessage(m *pmapi.Message, readers []io.Reader, kr *crypto.KeyRing) (err error) { // nolint[funlen] - b := &bytes.Buffer{} - - // Overwrite content for main header for import. - // Even if message has just simple body we should upload as multipart/mixed. - // Each part has encrypted body and header reflects the original header. - mainHeader := message.GetHeader(m) - mainHeader.Set("Content-Type", "multipart/mixed; boundary="+message.GetBoundary(m)) - mainHeader.Del("Content-Disposition") - mainHeader.Del("Content-Transfer-Encoding") - if err = writeHeader(b, mainHeader); err != nil { - return - } - mw := multipart.NewWriter(b) - if err = mw.SetBoundary(message.GetBoundary(m)); err != nil { - return - } - - // Write the body part. - bodyHeader := make(textproto.MIMEHeader) - bodyHeader.Set("Content-Type", m.MIMEType+"; charset=utf-8") - bodyHeader.Set("Content-Disposition", "inline") - bodyHeader.Set("Content-Transfer-Encoding", "7bit") - - var p io.Writer - if p, err = mw.CreatePart(bodyHeader); err != nil { - return - } - // First, encrypt the message body. - if err = m.Encrypt(kr, kr); err != nil { - return err - } - if _, err := io.WriteString(p, m.Body); err != nil { - return err - } - - // Write the attachments parts. - for i := 0; i < len(m.Attachments); i++ { - att := m.Attachments[i] - r := readers[i] - h := message.GetAttachmentHeader(att) - if p, err = mw.CreatePart(h); err != nil { - return - } - // Create line wrapper writer. - ww := textwrapper.NewRFC822(p) - - // Create base64 writer. - bw := base64.NewEncoder(base64.StdEncoding, ww) - - data, err := ioutil.ReadAll(r) - if err != nil { - return err - } - - // Create encrypted writer. - pgpMessage, err := kr.Encrypt(crypto.NewPlainMessage(data), nil) - if err != nil { - return err - } - if _, err := bw.Write(pgpMessage.GetBinary()); err != nil { - return err - } - if err := bw.Close(); err != nil { - return err - } - } - - if err := mw.Close(); err != nil { + body, err := message.BuildEncrypted(m, readers, kr) + if err != nil { return err } @@ -263,7 +193,7 @@ func (im *imapMailbox) importMessage(m *pmapi.Message, readers []io.Reader, kr * } } - return im.storeMailbox.ImportMessage(m, b.Bytes(), labels) + return im.storeMailbox.ImportMessage(m, body, labels) } func (im *imapMailbox) getMessage(storeMessage storeMessageProvider, items []imap.FetchItem) (msg *imap.Message, err error) { @@ -489,55 +419,6 @@ func (im *imapMailbox) fetchMessage(m *pmapi.Message) (err error) { return } -const customMessageTemplate = ` - - - -
- Decryption error
- Decryption of this message's encrypted content failed. -
{{.Error}}
-
- - {{if .AttachBody}} -
-
{{.Body}}
-
- {{- end}} - - -` - -type customMessageData struct { - Error string - AttachBody bool - Body string -} - -func (im *imapMailbox) makeCustomMessage(m *pmapi.Message, decodeError error, attachBody bool) (err error) { - t := template.Must(template.New("customMessage").Parse(customMessageTemplate)) - - b := new(bytes.Buffer) - - if err = t.Execute(b, customMessageData{ - Error: decodeError.Error(), - AttachBody: attachBody, - Body: m.Body, - }); err != nil { - return - } - - m.MIMEType = pmapi.ContentTypeHTML - m.Body = b.String() - - // NOTE: we need to set header in custom message header, so we check that is non-nil. - if m.Header == nil { - m.Header = make(mail.Header) - } - - return -} - func (im *imapMailbox) writeMessageBody(w io.Writer, m *pmapi.Message) (err error) { im.log.Trace("Writing message body") @@ -555,7 +436,7 @@ func (im *imapMailbox) writeMessageBody(w io.Writer, m *pmapi.Message) (err erro err = message.WriteBody(w, kr, m) if err != nil { - if customMessageErr := im.makeCustomMessage(m, err, true); customMessageErr != nil { + if customMessageErr := message.CustomMessage(m, err, true); customMessageErr != nil { im.log.WithError(customMessageErr).Warn("Failed to make custom message") } _, _ = io.WriteString(w, m.Body) @@ -692,7 +573,7 @@ func (im *imapMailbox) buildMessage(m *pmapi.Message) (structure *message.BodySt if errDecrypt != nil && errDecrypt != openpgperrors.ErrSignatureExpired { errNoCache.add(errDecrypt) - if customMessageErr := im.makeCustomMessage(m, errDecrypt, true); customMessageErr != nil { + if customMessageErr := message.CustomMessage(m, errDecrypt, true); customMessageErr != nil { im.log.WithError(customMessageErr).Warn("Failed to make custom message") } } @@ -708,7 +589,7 @@ func (im *imapMailbox) buildMessage(m *pmapi.Message) (structure *message.BodySt return nil, nil, err } else if err != nil { errNoCache.add(err) - if customMessageErr := im.makeCustomMessage(m, err, true); customMessageErr != nil { + if customMessageErr := message.CustomMessage(m, err, true); customMessageErr != nil { im.log.WithError(customMessageErr).Warn("Failed to make custom message") } structure, msgBody, err = im.buildMessageInner(m, kr) diff --git a/internal/importexport/credits.go b/internal/importexport/credits.go new file mode 100644 index 00000000..b3a2a91d --- /dev/null +++ b/internal/importexport/credits.go @@ -0,0 +1,22 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Code generated by ./credits.sh at Thu Jun 4 15:54:31 CEST 2020. DO NOT EDIT. + +package importexport + +const Credits = "github.com/0xAX/notificator;github.com/ProtonMail/bcrypt;github.com/ProtonMail/crypto;github.com/ProtonMail/docker-credential-helpers;github.com/ProtonMail/go-appdir;github.com/ProtonMail/go-apple-mobileconfig;github.com/ProtonMail/go-autostart;github.com/ProtonMail/go-imap-id;github.com/ProtonMail/go-smtp;github.com/ProtonMail/go-vcard;github.com/ProtonMail/gopenpgp;github.com/abiosoft/ishell;github.com/abiosoft/readline;github.com/allan-simon/go-singleinstance;github.com/andybalholm/cascadia;github.com/certifi/gocertifi;github.com/chzyer/logex;github.com/chzyer/test;github.com/cucumber/godog;github.com/docker/docker-credential-helpers;github.com/emersion/go-imap;github.com/emersion/go-imap-appendlimit;github.com/emersion/go-imap-idle;github.com/emersion/go-imap-move;github.com/emersion/go-imap-quota;github.com/emersion/go-imap-specialuse;github.com/emersion/go-imap-unselect;github.com/emersion/go-mbox;github.com/emersion/go-sasl;github.com/emersion/go-smtp;github.com/emersion/go-textwrapper;github.com/emersion/go-vcard;github.com/fatih/color;github.com/flynn-archive/go-shlex;github.com/getsentry/raven-go;github.com/go-resty/resty/v2;github.com/golang/mock;github.com/google/go-cmp;github.com/gopherjs/gopherjs;github.com/hashicorp/go-multierror;github.com/jameskeane/bcrypt;github.com/jaytaylor/html2text;github.com/jhillyerd/enmime;github.com/kardianos/osext;github.com/keybase/go-keychain;github.com/logrusorgru/aurora;github.com/miekg/dns;github.com/myesui/uuid;github.com/nsf/jsondiff;github.com/pkg/errors;github.com/sirupsen/logrus;github.com/skratchdot/open-golang;github.com/stretchr/testify;github.com/therecipe/qt;github.com/twinj/uuid;github.com/urfave/cli;go.etcd.io/bbolt;golang.org/x/crypto;golang.org/x/net;golang.org/x/text;gopkg.in/stretchr/testify.v1;;Font Awesome 4.7.0;;Qt 5.13 by Qt group;" diff --git a/internal/importexport/importexport.go b/internal/importexport/importexport.go new file mode 100644 index 00000000..569ce733 --- /dev/null +++ b/internal/importexport/importexport.go @@ -0,0 +1,113 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Package importexport provides core functionality of Import/Export app. +package importexport + +import ( + "github.com/ProtonMail/proton-bridge/internal/transfer" + "github.com/ProtonMail/proton-bridge/internal/users" + + "github.com/ProtonMail/proton-bridge/pkg/listener" + logrus "github.com/sirupsen/logrus" +) + +var ( + log = logrus.WithField("pkg", "importexport") //nolint[gochecknoglobals] +) + +type ImportExport struct { + *users.Users + + config Configer + panicHandler users.PanicHandler + clientManager users.ClientManager +} + +func New( + config Configer, + panicHandler users.PanicHandler, + eventListener listener.Listener, + clientManager users.ClientManager, + credStorer users.CredentialsStorer, +) *ImportExport { + u := users.New(config, panicHandler, eventListener, clientManager, credStorer, &storeFactory{}, false) + return &ImportExport{ + Users: u, + + config: config, + panicHandler: panicHandler, + clientManager: clientManager, + } +} + +// GetLocalImporter returns transferrer from local EML or MBOX structure to ProtonMail account. +func (ie *ImportExport) GetLocalImporter(address, path string) (*transfer.Transfer, error) { + source := transfer.NewLocalProvider(path) + target, err := ie.getPMAPIProvider(address) + if err != nil { + return nil, err + } + return transfer.New(ie.panicHandler, ie.config.GetTransferDir(), source, target) +} + +// GetRemoteImporter returns transferrer from remote IMAP to ProtonMail account. +func (ie *ImportExport) GetRemoteImporter(address, username, password, host, port string) (*transfer.Transfer, error) { + source, err := transfer.NewIMAPProvider(username, password, host, port) + if err != nil { + return nil, err + } + target, err := ie.getPMAPIProvider(address) + if err != nil { + return nil, err + } + return transfer.New(ie.panicHandler, ie.config.GetTransferDir(), source, target) +} + +// GetEMLExporter returns transferrer from ProtonMail account to local EML structure. +func (ie *ImportExport) GetEMLExporter(address, path string) (*transfer.Transfer, error) { + source, err := ie.getPMAPIProvider(address) + if err != nil { + return nil, err + } + target := transfer.NewEMLProvider(path) + return transfer.New(ie.panicHandler, ie.config.GetTransferDir(), source, target) +} + +// GetMBOXExporter returns transferrer from ProtonMail account to local MBOX structure. +func (ie *ImportExport) GetMBOXExporter(address, path string) (*transfer.Transfer, error) { + source, err := ie.getPMAPIProvider(address) + if err != nil { + return nil, err + } + target := transfer.NewMBOXProvider(path) + return transfer.New(ie.panicHandler, ie.config.GetTransferDir(), source, target) +} + +func (ie *ImportExport) getPMAPIProvider(address string) (*transfer.PMAPIProvider, error) { + user, err := ie.Users.GetUser(address) + if err != nil { + return nil, err + } + + addressID, err := user.GetAddressID(address) + if err != nil { + return nil, err + } + + return transfer.NewPMAPIProvider(ie.clientManager, user.ID(), addressID) +} diff --git a/internal/importexport/release_notes.go b/internal/importexport/release_notes.go new file mode 100644 index 00000000..456f472c --- /dev/null +++ b/internal/importexport/release_notes.go @@ -0,0 +1,26 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Code generated by ./release-notes.sh at 'Thu Jun 4 15:54:31 CEST 2020'. DO NOT EDIT. + +package importexport + +const ReleaseNotes = ` +` + +const ReleaseFixedBugs = ` +` diff --git a/internal/importexport/store_factory.go b/internal/importexport/store_factory.go new file mode 100644 index 00000000..9ce40b93 --- /dev/null +++ b/internal/importexport/store_factory.go @@ -0,0 +1,35 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package importexport + +import ( + "github.com/ProtonMail/proton-bridge/internal/store" +) + +// storeFactory implements dummy factory creating no store (not needed by Import/Export). +type storeFactory struct{} + +// New does nothing. +func (f *storeFactory) New(user store.BridgeUser) (*store.Store, error) { + return nil, nil +} + +// Remove does nothing. +func (f *storeFactory) Remove(userID string) error { + return nil +} diff --git a/internal/importexport/types.go b/internal/importexport/types.go new file mode 100644 index 00000000..da00b1a5 --- /dev/null +++ b/internal/importexport/types.go @@ -0,0 +1,26 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package importexport + +import "github.com/ProtonMail/proton-bridge/internal/users" + +type Configer interface { + users.Configer + + GetTransferDir() string +} diff --git a/internal/transfer/mailbox.go b/internal/transfer/mailbox.go new file mode 100644 index 00000000..556c3218 --- /dev/null +++ b/internal/transfer/mailbox.go @@ -0,0 +1,69 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +import ( + "crypto/sha256" + "fmt" + "strings" +) + +// Mailbox is universal data holder of mailbox details for every provider. +type Mailbox struct { + ID string + Name string + Color string + IsExclusive bool +} + +// Hash returns unique identifier to be used for matching. +func (m Mailbox) Hash() string { + return fmt.Sprintf("%x", sha256.Sum256([]byte(m.Name))) +} + +// findMatchingMailboxes returns all matching mailboxes from `mailboxes`. +// Only one exclusive mailbox is returned. +func (m Mailbox) findMatchingMailboxes(mailboxes []Mailbox) []Mailbox { + nameVariants := []string{} + if strings.Contains(m.Name, "/") || strings.Contains(m.Name, "|") { + for _, slashPart := range strings.Split(m.Name, "/") { + for _, part := range strings.Split(slashPart, "|") { + nameVariants = append(nameVariants, strings.ToLower(part)) + } + } + } + nameVariants = append(nameVariants, strings.ToLower(m.Name)) + + isExclusiveIncluded := false + matches := []Mailbox{} + for i := range nameVariants { + nameVariant := nameVariants[len(nameVariants)-1-i] + for _, mailbox := range mailboxes { + if mailbox.IsExclusive && isExclusiveIncluded { + continue + } + if strings.ToLower(mailbox.Name) == nameVariant { + matches = append(matches, mailbox) + if mailbox.IsExclusive { + isExclusiveIncluded = true + } + } + } + } + return matches +} diff --git a/internal/transfer/mailbox_test.go b/internal/transfer/mailbox_test.go new file mode 100644 index 00000000..c59b4581 --- /dev/null +++ b/internal/transfer/mailbox_test.go @@ -0,0 +1,61 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +import ( + "testing" + + r "github.com/stretchr/testify/require" +) + +func TestFindMatchingMailboxes(t *testing.T) { + mailboxes := []Mailbox{ + {Name: "Inbox", IsExclusive: true}, + {Name: "Sent", IsExclusive: true}, + {Name: "Archive", IsExclusive: true}, + {Name: "Foo", IsExclusive: false}, + {Name: "hello/world", IsExclusive: true}, + {Name: "Hello", IsExclusive: false}, + {Name: "WORLD", IsExclusive: true}, + } + + tests := []struct { + name string + wantNames []string + }{ + {"inbox", []string{"Inbox"}}, + {"foo", []string{"Foo"}}, + {"hello", []string{"Hello"}}, + {"world", []string{"WORLD"}}, + {"hello/world", []string{"hello/world", "Hello"}}, + {"hello|world", []string{"WORLD", "Hello"}}, + {"nomailbox", []string{}}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + mailbox := Mailbox{Name: tc.name} + got := mailbox.findMatchingMailboxes(mailboxes) + gotNames := []string{} + for _, m := range got { + gotNames = append(gotNames, m.Name) + } + r.Equal(t, tc.wantNames, gotNames) + }) + } +} diff --git a/internal/transfer/message.go b/internal/transfer/message.go new file mode 100644 index 00000000..5c939305 --- /dev/null +++ b/internal/transfer/message.go @@ -0,0 +1,100 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +import ( + "fmt" + "mime" + "net/mail" + "time" +) + +// Message is data holder passed between import and export. +type Message struct { + ID string + Unread bool + Body []byte + Source Mailbox + Targets []Mailbox +} + +// MessageStatus holds status for message used by progress manager. +type MessageStatus struct { + eventTime time.Time // Time of adding message to the process. + rule *Rule // Rule with source and target mailboxes. + SourceID string // Message ID at the source. + targetID string // Message ID at the target (if any). + bodyHash string // Hash of the message body. + + exported bool + imported bool + exportErr error + importErr error + + // Info about message displayed to user. + // This is needed only for failed messages, but we cannot know in advance + // which message will fail. We could clear it once the message passed + // without any error. However, if we say one message takes about 100 bytes + // in average, it's about 100 MB per million of messages, which is fine. + Subject string + From string + Time time.Time +} + +func (status *MessageStatus) setDetailsFromHeader(header mail.Header) { + dec := &mime.WordDecoder{} + + status.Subject = header.Get("subject") + if subject, err := dec.Decode(status.Subject); err == nil { + status.Subject = subject + } + + status.From = header.Get("from") + if from, err := dec.Decode(status.From); err == nil { + status.From = from + } + + if msgTime, err := header.Date(); err == nil { + status.Time = msgTime + } +} + +func (status *MessageStatus) hasError(includeMissing bool) bool { + return status.exportErr != nil || status.importErr != nil || (includeMissing && !status.imported) +} + +// GetErrorMessage returns error message. +func (status *MessageStatus) GetErrorMessage() string { + return status.getErrorMessage(true) +} + +func (status *MessageStatus) getErrorMessage(includeMissing bool) string { + if status.exportErr != nil { + return fmt.Sprintf("failed to export: %s", status.exportErr) + } + if status.importErr != nil { + return fmt.Sprintf("failed to import: %s", status.importErr) + } + if includeMissing && !status.imported { + if !status.exported { + return "failed to import: lost before read" + } + return "failed to import: lost in the process" + } + return "" +} diff --git a/internal/transfer/mocks/mocks.go b/internal/transfer/mocks/mocks.go new file mode 100644 index 00000000..bc18f2b7 --- /dev/null +++ b/internal/transfer/mocks/mocks.go @@ -0,0 +1,97 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/ProtonMail/proton-bridge/internal/transfer (interfaces: PanicHandler,ClientManager) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + pmapi "github.com/ProtonMail/proton-bridge/pkg/pmapi" + gomock "github.com/golang/mock/gomock" + reflect "reflect" +) + +// MockPanicHandler is a mock of PanicHandler interface +type MockPanicHandler struct { + ctrl *gomock.Controller + recorder *MockPanicHandlerMockRecorder +} + +// MockPanicHandlerMockRecorder is the mock recorder for MockPanicHandler +type MockPanicHandlerMockRecorder struct { + mock *MockPanicHandler +} + +// NewMockPanicHandler creates a new mock instance +func NewMockPanicHandler(ctrl *gomock.Controller) *MockPanicHandler { + mock := &MockPanicHandler{ctrl: ctrl} + mock.recorder = &MockPanicHandlerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockPanicHandler) EXPECT() *MockPanicHandlerMockRecorder { + return m.recorder +} + +// HandlePanic mocks base method +func (m *MockPanicHandler) HandlePanic() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "HandlePanic") +} + +// HandlePanic indicates an expected call of HandlePanic +func (mr *MockPanicHandlerMockRecorder) HandlePanic() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandlePanic", reflect.TypeOf((*MockPanicHandler)(nil).HandlePanic)) +} + +// MockClientManager is a mock of ClientManager interface +type MockClientManager struct { + ctrl *gomock.Controller + recorder *MockClientManagerMockRecorder +} + +// MockClientManagerMockRecorder is the mock recorder for MockClientManager +type MockClientManagerMockRecorder struct { + mock *MockClientManager +} + +// NewMockClientManager creates a new mock instance +func NewMockClientManager(ctrl *gomock.Controller) *MockClientManager { + mock := &MockClientManager{ctrl: ctrl} + mock.recorder = &MockClientManagerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockClientManager) EXPECT() *MockClientManagerMockRecorder { + return m.recorder +} + +// CheckConnection mocks base method +func (m *MockClientManager) CheckConnection() error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CheckConnection") + ret0, _ := ret[0].(error) + return ret0 +} + +// CheckConnection indicates an expected call of CheckConnection +func (mr *MockClientManagerMockRecorder) CheckConnection() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CheckConnection", reflect.TypeOf((*MockClientManager)(nil).CheckConnection)) +} + +// GetClient mocks base method +func (m *MockClientManager) GetClient(arg0 string) pmapi.Client { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetClient", arg0) + ret0, _ := ret[0].(pmapi.Client) + return ret0 +} + +// GetClient indicates an expected call of GetClient +func (mr *MockClientManagerMockRecorder) GetClient(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClient", reflect.TypeOf((*MockClientManager)(nil).GetClient), arg0) +} diff --git a/internal/transfer/progress.go b/internal/transfer/progress.go new file mode 100644 index 00000000..ee463f45 --- /dev/null +++ b/internal/transfer/progress.go @@ -0,0 +1,331 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +import ( + "crypto/sha256" + "fmt" + "sync" + "time" + + "github.com/sirupsen/logrus" +) + +// Progress maintains progress between import, export and user interface. +// Import and export update progress about processing messages and progress +// informs user interface, vice versa action (such as pause or resume) from +// user interface is passed down to import and export. +type Progress struct { + log *logrus.Entry + lock sync.RWMutex + + updateCh chan struct{} + messageCounts map[string]uint + messageStatuses map[string]*MessageStatus + pauseReason string + isStopped bool + fatalError error + fileReport *fileReport +} + +func newProgress(log *logrus.Entry, fileReport *fileReport) Progress { + return Progress{ + log: log, + + updateCh: make(chan struct{}), + messageCounts: map[string]uint{}, + messageStatuses: map[string]*MessageStatus{}, + fileReport: fileReport, + } +} + +// update is helper to notify listener for updates. +func (p *Progress) update() { + if p.updateCh == nil { + // If the progress was ended by fatal instead finish, we ignore error. + if p.fatalError != nil { + return + } + panic("update should not be called after finish was called") + } + + // In case no one listens for an update, do not block the progress. + select { + case p.updateCh <- struct{}{}: + case <-time.After(100 * time.Millisecond): + } +} + +// start should be called before anything starts. +func (p *Progress) start() { + p.lock.Lock() + defer p.lock.Unlock() +} + +// finish should be called as the last call once everything is done. +func (p *Progress) finish() { + p.lock.Lock() + defer p.lock.Unlock() + + p.cleanUpdateCh() +} + +// fatal should be called once there is error with no possible continuation. +func (p *Progress) fatal(err error) { + p.lock.Lock() + defer p.lock.Unlock() + + p.isStopped = true + p.fatalError = err + p.cleanUpdateCh() +} + +func (p *Progress) cleanUpdateCh() { + if p.updateCh == nil { + // If the progress was ended by fatal instead finish, we ignore error. + if p.fatalError != nil { + return + } + panic("update should not be called after finish was called") + } + + close(p.updateCh) + p.updateCh = nil +} + +func (p *Progress) updateCount(mailbox string, count uint) { + p.lock.Lock() + defer p.update() + defer p.lock.Unlock() + + log.WithField("mailbox", mailbox).WithField("count", count).Debug("Mailbox count updated") + p.messageCounts[mailbox] = count +} + +// addMessage should be called as soon as there is ID of the message. +func (p *Progress) addMessage(messageID string, rule *Rule) { + p.lock.Lock() + defer p.update() + defer p.lock.Unlock() + + p.log.WithField("id", messageID).Trace("Message added") + p.messageStatuses[messageID] = &MessageStatus{ + eventTime: time.Now(), + rule: rule, + SourceID: messageID, + } +} + +// messageExported should be called right before message is exported. +func (p *Progress) messageExported(messageID string, body []byte, err error) { + p.lock.Lock() + defer p.update() + defer p.lock.Unlock() + + p.log.WithField("id", messageID).WithError(err).Debug("Message exported") + status := p.messageStatuses[messageID] + status.exported = true + status.exportErr = err + + if len(body) > 0 { + status.bodyHash = fmt.Sprintf("%x", sha256.Sum256(body)) + + if header, err := getMessageHeader(body); err != nil { + p.log.WithField("id", messageID).WithError(err).Warning("Failed to parse headers for reporting") + } else { + status.setDetailsFromHeader(header) + } + } + + // If export failed, no other step will be done with message and we can log it to the report file. + if err != nil { + p.logMessage(messageID) + } +} + +// messageImported should be called right after message is imported. +func (p *Progress) messageImported(messageID, importID string, err error) { + p.lock.Lock() + defer p.update() + defer p.lock.Unlock() + + p.log.WithField("id", messageID).WithError(err).Debug("Message imported") + p.messageStatuses[messageID].targetID = importID + p.messageStatuses[messageID].imported = true + p.messageStatuses[messageID].importErr = err + + // Import is the last step, now we can log the result to the report file. + p.logMessage(messageID) +} + +// logMessage writes message status to log file. +func (p *Progress) logMessage(messageID string) { + if p.fileReport == nil { + return + } + p.fileReport.writeMessageStatus(p.messageStatuses[messageID]) +} + +// callWrap calls the callback and in case of problem it pause the process. +// Then it waits for user action to fix it and click on continue or abort. +func (p *Progress) callWrap(callback func() error) { + for { + if p.shouldStop() { + break + } + + err := callback() + if err == nil { + break + } + + p.Pause(err.Error()) + } +} + +// shouldStop is utility for providers to automatically wait during pause +// and returned value determines whether the process shouls be fully stopped. +func (p *Progress) shouldStop() bool { + for p.IsPaused() { + time.Sleep(time.Second) + } + return p.IsStopped() +} + +// GetUpdateChannel returns channel notifying any update from import or export. +func (p *Progress) GetUpdateChannel() chan struct{} { + p.lock.Lock() + defer p.lock.Unlock() + + return p.updateCh +} + +// Pause pauses the progress. +func (p *Progress) Pause(reason string) { + p.lock.Lock() + defer p.update() + defer p.lock.Unlock() + + p.log.Info("Progress paused") + p.pauseReason = reason +} + +// Resume resumes the progress. +func (p *Progress) Resume() { + p.lock.Lock() + defer p.update() + defer p.lock.Unlock() + + p.log.Info("Progress resumed") + p.pauseReason = "" +} + +// IsPaused returns whether progress is paused. +func (p *Progress) IsPaused() bool { + p.lock.Lock() + defer p.lock.Unlock() + + return p.pauseReason != "" +} + +// PauseReason returns pause reason. +func (p *Progress) PauseReason() string { + p.lock.Lock() + defer p.lock.Unlock() + + return p.pauseReason +} + +// Stop stops the process. +func (p *Progress) Stop() { + p.lock.Lock() + defer p.update() + defer p.lock.Unlock() + + p.log.Info("Progress stopped") + p.isStopped = true + p.pauseReason = "" // Clear pause to run paused code and stop it. +} + +// IsStopped returns whether progress is stopped. +func (p *Progress) IsStopped() bool { + p.lock.Lock() + defer p.lock.Unlock() + + return p.isStopped +} + +// GetFatalError returns fatal error (progress failed and did not finish). +func (p *Progress) GetFatalError() error { + p.lock.Lock() + defer p.lock.Unlock() + + return p.fatalError +} + +// GetFailedMessages returns statuses of failed messages. +func (p *Progress) GetFailedMessages() []*MessageStatus { + p.lock.Lock() + defer p.lock.Unlock() + + // Include lost messages in the process only when transfer is done. + includeMissing := p.updateCh == nil + + statuses := []*MessageStatus{} + for _, status := range p.messageStatuses { + if status.hasError(includeMissing) { + statuses = append(statuses, status) + } + } + return statuses +} + +// GetCounts returns counts of exported and imported messages. +func (p *Progress) GetCounts() (failed, imported, exported, added, total uint) { + p.lock.Lock() + defer p.lock.Unlock() + + // Include lost messages in the process only when transfer is done. + includeMissing := p.updateCh == nil + + for _, mailboxCount := range p.messageCounts { + total += mailboxCount + } + for _, status := range p.messageStatuses { + added++ + if status.exported { + exported++ + } + if status.imported { + imported++ + } + if status.hasError(includeMissing) { + failed++ + } + } + return +} + +// GenerateBugReport generates similar file to import log except private information. +func (p *Progress) GenerateBugReport() []byte { + bugReport := bugReport{} + for _, status := range p.messageStatuses { + bugReport.writeMessageStatus(status) + } + return bugReport.getData() +} diff --git a/internal/transfer/progress_test.go b/internal/transfer/progress_test.go new file mode 100644 index 00000000..68baeb16 --- /dev/null +++ b/internal/transfer/progress_test.go @@ -0,0 +1,120 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +import ( + "testing" + + "github.com/pkg/errors" + a "github.com/stretchr/testify/assert" + r "github.com/stretchr/testify/require" +) + +func TestProgressUpdateCount(t *testing.T) { + progress := newProgress(log, nil) + drainProgressUpdateChannel(&progress) + + progress.start() + + progress.updateCount("inbox", 10) + progress.updateCount("archive", 20) + progress.updateCount("inbox", 12) + progress.updateCount("sent", 5) + progress.updateCount("foo", 4) + progress.updateCount("foo", 5) + + progress.finish() + + _, _, _, _, total := progress.GetCounts() //nolint[dogsled] + r.Equal(t, uint(42), total) +} + +func TestProgressAddingMessages(t *testing.T) { + progress := newProgress(log, nil) + drainProgressUpdateChannel(&progress) + + progress.start() + + // msg1 has no problem. + progress.addMessage("msg1", nil) + progress.messageExported("msg1", []byte(""), nil) + progress.messageImported("msg1", "", nil) + + // msg2 has an import problem. + progress.addMessage("msg2", nil) + progress.messageExported("msg2", []byte(""), nil) + progress.messageImported("msg2", "", errors.New("failed import")) + + // msg3 has an export problem. + progress.addMessage("msg3", nil) + progress.messageExported("msg3", []byte(""), errors.New("failed export")) + + // msg4 has an export problem and import is also called. + progress.addMessage("msg4", nil) + progress.messageExported("msg4", []byte(""), errors.New("failed export")) + progress.messageImported("msg4", "", nil) + + progress.finish() + + failed, imported, exported, added, _ := progress.GetCounts() + a.Equal(t, uint(4), added) + a.Equal(t, uint(4), exported) + a.Equal(t, uint(3), imported) + a.Equal(t, uint(3), failed) + + errorsMap := map[string]string{} + for _, status := range progress.GetFailedMessages() { + errorsMap[status.SourceID] = status.GetErrorMessage() + } + a.Equal(t, map[string]string{ + "msg2": "failed to import: failed import", + "msg3": "failed to export: failed export", + "msg4": "failed to export: failed export", + }, errorsMap) +} + +func TestProgressFinish(t *testing.T) { + progress := newProgress(log, nil) + drainProgressUpdateChannel(&progress) + + progress.start() + progress.finish() + r.Nil(t, progress.updateCh) + + r.Panics(t, func() { progress.addMessage("msg", nil) }) +} + +func TestProgressFatalError(t *testing.T) { + progress := newProgress(log, nil) + drainProgressUpdateChannel(&progress) + + progress.start() + progress.fatal(errors.New("fatal error")) + r.Nil(t, progress.updateCh) + + r.NotPanics(t, func() { progress.addMessage("msg", nil) }) +} + +func drainProgressUpdateChannel(progress *Progress) { + // updateCh is not needed to drain under tests - timeout is implemented. + // But timeout takes time which would slow down tests. + go func() { + for range progress.updateCh { + } + }() +} diff --git a/internal/transfer/provider.go b/internal/transfer/provider.go new file mode 100644 index 00000000..34048013 --- /dev/null +++ b/internal/transfer/provider.go @@ -0,0 +1,51 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +// Provider provides interface for common operation with provider. +type Provider interface { + // ID is used for generating transfer ID by combining source and target ID. + ID() string + + // Mailboxes returns all available mailboxes. + // Provider used as source returns only non-empty maibloxes. + // Provider used as target does not return all mail maiblox. + Mailboxes(includeEmpty, includeAllMail bool) ([]Mailbox, error) +} + +// SourceProvider provides interface of provider with support of export. +type SourceProvider interface { + Provider + + // TransferTo exports messages based on rules to channel. + TransferTo(transferRules, *Progress, chan<- Message) +} + +// TargetProvider provides interface of provider with support of import. +type TargetProvider interface { + Provider + + // DefaultMailboxes returns the default mailboxes for default rules if no other is found. + DefaultMailboxes(sourceMailbox Mailbox) (targetMailboxes []Mailbox) + + // CreateMailbox creates new mailbox to be used as target in transfer rules. + CreateMailbox(Mailbox) (Mailbox, error) + + // TransferFrom imports messages from channel. + TransferFrom(transferRules, *Progress, <-chan Message) +} diff --git a/internal/transfer/provider_eml.go b/internal/transfer/provider_eml.go new file mode 100644 index 00000000..051afe1f --- /dev/null +++ b/internal/transfer/provider_eml.go @@ -0,0 +1,65 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +// EMLProvider implements import and export to/from EML file structure. +type EMLProvider struct { + root string +} + +// NewEMLProvider creates EMLProvider. +func NewEMLProvider(root string) *EMLProvider { + return &EMLProvider{ + root: root, + } +} + +// ID is used for generating transfer ID by combining source and target ID. +// We want to keep the same rules for import from or export to local files +// no matter exact path, therefore it returns constant. The same as EML. +func (p *EMLProvider) ID() string { + return "local" //nolint[goconst] +} + +// Mailboxes returns all available folder names from root of EML files. +// In case the same folder name is used more than once (for example root/a/foo +// and root/b/foo), it's treated as the same folder. +func (p *EMLProvider) Mailboxes(includeEmpty, includeAllMail bool) ([]Mailbox, error) { + var folderNames []string + var err error + if includeEmpty { + folderNames, err = getFolderNames(p.root) + } else { + folderNames, err = getFolderNamesWithFileSuffix(p.root, ".eml") + } + if err != nil { + return nil, err + } + + mailboxes := []Mailbox{} + for _, folderName := range folderNames { + mailboxes = append(mailboxes, Mailbox{ + ID: "", + Name: folderName, + Color: "", + IsExclusive: false, + }) + } + + return mailboxes, nil +} diff --git a/internal/transfer/provider_eml_source.go b/internal/transfer/provider_eml_source.go new file mode 100644 index 00000000..7eb949b8 --- /dev/null +++ b/internal/transfer/provider_eml_source.go @@ -0,0 +1,135 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +import ( + "io/ioutil" + "os" + "path/filepath" + + "github.com/pkg/errors" +) + +// TransferTo exports messages based on rules to channel. +func (p *EMLProvider) TransferTo(rules transferRules, progress *Progress, ch chan<- Message) { + log.Info("Started transfer from EML to channel") + defer log.Info("Finished transfer from EML to channel") + + filePathsPerFolder, err := p.getFilePathsPerFolder(rules) + if err != nil { + progress.fatal(err) + return + } + + // This list is not filtered by time but instead going throgh each file + // twice or keeping all in memory we will tell rough estimation which + // will be updated during processing each file. + for folderName, filePaths := range filePathsPerFolder { + if progress.shouldStop() { + break + } + + progress.updateCount(folderName, uint(len(filePaths))) + } + + for folderName, filePaths := range filePathsPerFolder { + // No error guaranteed by getFilePathsPerFolder. + rule, _ := rules.getRuleBySourceMailboxName(folderName) + log.WithField("rule", rule).Debug("Processing rule") + p.exportMessages(rule, filePaths, progress, ch) + } +} + +func (p *EMLProvider) getFilePathsPerFolder(rules transferRules) (map[string][]string, error) { + filePaths, err := getFilePathsWithSuffix(p.root, ".eml") + if err != nil { + return nil, err + } + + filePathsMap := map[string][]string{} + for _, filePath := range filePaths { + folder := filepath.Base(filepath.Dir(filepath.Join(p.root, filePath))) + _, err := rules.getRuleBySourceMailboxName(folder) + if err != nil { + log.WithField("msg", filePath).Trace("Message skipped due to folder name") + continue + } + + filePathsMap[folder] = append(filePathsMap[folder], filePath) + } + + return filePathsMap, nil +} + +func (p *EMLProvider) exportMessages(rule *Rule, filePaths []string, progress *Progress, ch chan<- Message) { + count := uint(len(filePaths)) + + for _, filePath := range filePaths { + if progress.shouldStop() { + break + } + + msg, err := p.exportMessage(rule, filePath) + + // Read and check time in body only if the rule specifies it + // to not waste energy. + if err == nil && rule.HasTimeLimit() { + msgTime, msgTimeErr := getMessageTime(msg.Body) + if msgTimeErr != nil { + err = msgTimeErr + } else if !rule.isTimeInRange(msgTime) { + log.WithField("msg", filePath).Debug("Message skipped due to time") + + count-- + progress.updateCount(rule.SourceMailbox.Name, count) + continue + } + } + + // addMessage is called after time check to not report message + // which should not be exported but any error from reading body + // or parsing time is reported as an error. + progress.addMessage(filePath, rule) + progress.messageExported(filePath, msg.Body, err) + if err == nil { + ch <- msg + } + } +} + +func (p *EMLProvider) exportMessage(rule *Rule, filePath string) (Message, error) { + fullFilePath := filepath.Clean(filepath.Join(p.root, filePath)) + file, err := os.Open(fullFilePath) //nolint[gosec] + if err != nil { + return Message{}, errors.Wrap(err, "failed to open message") + } + defer file.Close() //nolint[errcheck] + + body, err := ioutil.ReadAll(file) + if err != nil { + return Message{}, errors.Wrap(err, "failed to read message") + } + + return Message{ + ID: filePath, + Unread: false, + Body: body, + Source: rule.SourceMailbox, + Targets: rule.TargetMailboxes, + }, nil +} diff --git a/internal/transfer/provider_eml_target.go b/internal/transfer/provider_eml_target.go new file mode 100644 index 00000000..58f7b9ac --- /dev/null +++ b/internal/transfer/provider_eml_target.go @@ -0,0 +1,89 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +import ( + "io/ioutil" + "os" + "path/filepath" + "strings" + + "github.com/hashicorp/go-multierror" +) + +// DefaultMailboxes returns the default mailboxes for default rules if no other is found. +func (p *EMLProvider) DefaultMailboxes(sourceMailbox Mailbox) []Mailbox { + return []Mailbox{{ + Name: sourceMailbox.Name, + }} +} + +// CreateMailbox does nothing. Folders are created dynamically during the import. +func (p *EMLProvider) CreateMailbox(mailbox Mailbox) (Mailbox, error) { + return mailbox, nil +} + +// TransferFrom imports messages from channel. +func (p *EMLProvider) TransferFrom(rules transferRules, progress *Progress, ch <-chan Message) { + log.Info("Started transfer from channel to EML") + defer log.Info("Finished transfer from channel to EML") + + err := p.createFolders(rules) + if err != nil { + progress.fatal(err) + return + } + + for msg := range ch { + for progress.shouldStop() { + break + } + + err := p.writeFile(msg) + progress.messageImported(msg.ID, "", err) + } +} + +func (p *EMLProvider) createFolders(rules transferRules) error { + for rule := range rules.iterateActiveRules() { + for _, mailbox := range rule.TargetMailboxes { + path := filepath.Join(p.root, mailbox.Name) + if err := os.MkdirAll(path, os.ModePerm); err != nil { + return err + } + } + } + return nil +} + +func (p *EMLProvider) writeFile(msg Message) error { + fileName := filepath.Base(msg.ID) + if !strings.HasSuffix(fileName, ".eml") { + fileName += ".eml" + } + + var err error + for _, mailbox := range msg.Targets { + path := filepath.Join(p.root, mailbox.Name, fileName) + + if localErr := ioutil.WriteFile(path, msg.Body, 0600); localErr != nil { + err = multierror.Append(err, localErr) + } + } + return err +} diff --git a/internal/transfer/provider_eml_test.go b/internal/transfer/provider_eml_test.go new file mode 100644 index 00000000..76f499b6 --- /dev/null +++ b/internal/transfer/provider_eml_test.go @@ -0,0 +1,126 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +import ( + "fmt" + "io/ioutil" + "os" + "testing" + "time" + + r "github.com/stretchr/testify/require" +) + +func newTestEMLProvider(path string) *EMLProvider { + if path == "" { + path = "testdata/eml" + } + return NewEMLProvider(path) +} + +func TestEMLProviderMailboxes(t *testing.T) { + provider := newTestEMLProvider("") + + tests := []struct { + includeEmpty bool + wantMailboxes []Mailbox + }{ + {true, []Mailbox{ + {Name: "Foo"}, + {Name: "Inbox"}, + {Name: "eml"}, + }}, + {false, []Mailbox{ + {Name: "Foo"}, + {Name: "Inbox"}, + }}, + } + for _, tc := range tests { + tc := tc + t.Run(fmt.Sprintf("%v", tc.includeEmpty), func(t *testing.T) { + mailboxes, err := provider.Mailboxes(tc.includeEmpty, false) + r.NoError(t, err) + r.Equal(t, tc.wantMailboxes, mailboxes) + }) + } +} + +func TestEMLProviderTransferTo(t *testing.T) { + provider := newTestEMLProvider("") + + rules, rulesClose := newTestRules(t) + defer rulesClose() + setupEMLRules(rules) + + testTransferTo(t, rules, provider, []string{ + "Foo/msg.eml", + "Inbox/msg.eml", + }) +} + +func TestEMLProviderTransferFrom(t *testing.T) { + dir, err := ioutil.TempDir("", "eml") + r.NoError(t, err) + defer os.RemoveAll(dir) //nolint[errcheck] + + provider := newTestEMLProvider(dir) + + rules, rulesClose := newTestRules(t) + defer rulesClose() + setupEMLRules(rules) + + testTransferFrom(t, rules, provider, []Message{ + {ID: "Foo/msg.eml", Body: getTestMsgBody("msg"), Targets: []Mailbox{{Name: "Foo"}}}, + }) + + checkEMLFileStructure(t, dir, []string{ + "Foo/msg.eml", + }) +} + +func TestEMLProviderTransferFromTo(t *testing.T) { + dir, err := ioutil.TempDir("", "eml") + r.NoError(t, err) + defer os.RemoveAll(dir) //nolint[errcheck] + + source := newTestEMLProvider("") + target := newTestEMLProvider(dir) + + rules, rulesClose := newTestRules(t) + defer rulesClose() + setupEMLRules(rules) + + testTransferFromTo(t, rules, source, target, 5*time.Second) + + checkEMLFileStructure(t, dir, []string{ + "Foo/msg.eml", + "Inbox/msg.eml", + }) +} + +func setupEMLRules(rules transferRules) { + _ = rules.setRule(Mailbox{Name: "Inbox"}, []Mailbox{{Name: "Inbox"}}, 0, 0) + _ = rules.setRule(Mailbox{Name: "Foo"}, []Mailbox{{Name: "Foo"}}, 0, 0) +} + +func checkEMLFileStructure(t *testing.T, root string, expectedFiles []string) { + files, err := getFilePathsWithSuffix(root, ".eml") + r.NoError(t, err) + r.Equal(t, expectedFiles, files) +} diff --git a/internal/transfer/provider_imap.go b/internal/transfer/provider_imap.go new file mode 100644 index 00000000..16e7d493 --- /dev/null +++ b/internal/transfer/provider_imap.go @@ -0,0 +1,98 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +import ( + "net" + "strings" + + imapClient "github.com/emersion/go-imap/client" +) + +// IMAPProvider implements export from IMAP server. +type IMAPProvider struct { + username string + password string + addr string + + client *imapClient.Client +} + +// NewIMAPProvider returns new IMAPProvider. +func NewIMAPProvider(username, password, host, port string) (*IMAPProvider, error) { + p := &IMAPProvider{ + username: username, + password: password, + addr: net.JoinHostPort(host, port), + } + + if err := p.auth(); err != nil { + return nil, err + } + + return p, nil +} + +// ID is used for generating transfer ID by combining source and target ID. +// We want to keep the same rules for import from any IMAP server, therefore +// it returns constant. +func (p *IMAPProvider) ID() string { + return "imap" +} + +// Mailboxes returns all available folder names from root of EML files. +// In case the same folder name is used more than once (for example root/a/foo +// and root/b/foo), it's treated as the same folder. +func (p *IMAPProvider) Mailboxes(includEmpty, includeAllMail bool) ([]Mailbox, error) { + mailboxesInfo, err := p.list() + if err != nil { + return nil, err + } + + mailboxes := []Mailbox{} + for _, mailbox := range mailboxesInfo { + hasNoSelect := false + for _, attrib := range mailbox.Attributes { + if strings.ToLower(attrib) == "\\noselect" { + hasNoSelect = true + break + } + } + if hasNoSelect || mailbox.Name == "[Gmail]" { + continue + } + + if !includEmpty || true { + mailboxStatus, err := p.selectIn(mailbox.Name) + if err != nil { + return nil, err + } + if mailboxStatus.Messages == 0 { + continue + } + } + + mailboxes = append(mailboxes, Mailbox{ + ID: "", + Name: mailbox.Name, + Color: "", + IsExclusive: false, + }) + } + return mailboxes, nil +} diff --git a/internal/transfer/provider_imap_source.go b/internal/transfer/provider_imap_source.go new file mode 100644 index 00000000..59dbf9ac --- /dev/null +++ b/internal/transfer/provider_imap_source.go @@ -0,0 +1,210 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +import ( + "fmt" + "io/ioutil" + + "github.com/emersion/go-imap" +) + +type imapMessageInfo struct { + id string + uid uint32 + size uint32 +} + +const ( + imapPageSize = uint32(2000) // Optimized on Gmail. + imapMaxFetchSize = uint32(50 * 1000 * 1000) // Size in octets. If 0, it will use one fetch per message. +) + +// TransferTo exports messages based on rules to channel. +func (p *IMAPProvider) TransferTo(rules transferRules, progress *Progress, ch chan<- Message) { + log.Info("Started transfer from IMAP to channel") + defer log.Info("Finished transfer from IMAP to channel") + + imapMessageInfoMap := p.loadMessageInfoMap(rules, progress) + + for rule := range rules.iterateActiveRules() { + log.WithField("rule", rule).Debug("Processing rule") + messagesInfo := imapMessageInfoMap[rule.SourceMailbox.Name] + p.transferTo(rule, messagesInfo, progress, ch) + } +} + +func (p *IMAPProvider) loadMessageInfoMap(rules transferRules, progress *Progress) map[string]map[string]imapMessageInfo { + res := map[string]map[string]imapMessageInfo{} + + for rule := range rules.iterateActiveRules() { + if progress.shouldStop() { + break + } + + mailboxName := rule.SourceMailbox.Name + var mailbox *imap.MailboxStatus + progress.callWrap(func() error { + var err error + mailbox, err = p.selectIn(mailboxName) + return err + }) + if mailbox.Messages == 0 { + continue + } + + messagesInfo := p.loadMessagesInfo(rule, progress, mailbox.UidValidity) + res[rule.SourceMailbox.Name] = messagesInfo + progress.updateCount(rule.SourceMailbox.Name, uint(len(messagesInfo))) + } + + return res +} + +func (p *IMAPProvider) loadMessagesInfo(rule *Rule, progress *Progress, uidValidity uint32) map[string]imapMessageInfo { + messagesInfo := map[string]imapMessageInfo{} + + pageStart := uint32(1) + pageEnd := imapPageSize + for { + if progress.shouldStop() { + break + } + + seqSet := &imap.SeqSet{} + seqSet.AddRange(pageStart, pageEnd) + + items := []imap.FetchItem{imap.FetchUid, imap.FetchRFC822Size} + if rule.HasTimeLimit() { + items = append(items, imap.FetchEnvelope) + } + + pageMsgCount := uint32(0) + processMessageCallback := func(imapMessage *imap.Message) { + pageMsgCount++ + if rule.HasTimeLimit() { + t := imapMessage.Envelope.Date.Unix() + if t != 0 && !rule.isTimeInRange(t) { + log.WithField("uid", imapMessage.Uid).Debug("Message skipped due to time") + return + } + } + id := fmt.Sprintf("%s_%d:%d", rule.SourceMailbox.Name, uidValidity, imapMessage.Uid) + messagesInfo[id] = imapMessageInfo{ + id: id, + uid: imapMessage.Uid, + size: imapMessage.Size, + } + progress.addMessage(id, rule) + } + + progress.callWrap(func() error { + return p.fetch(seqSet, items, processMessageCallback) + }) + + if pageMsgCount < imapPageSize { + break + } + + pageStart = pageEnd + pageEnd += imapPageSize + } + + return messagesInfo +} + +func (p *IMAPProvider) transferTo(rule *Rule, messagesInfo map[string]imapMessageInfo, progress *Progress, ch chan<- Message) { + progress.callWrap(func() error { + _, err := p.selectIn(rule.SourceMailbox.Name) + return err + }) + + seqSet := &imap.SeqSet{} + seqSetSize := uint32(0) + uidToID := map[uint32]string{} + + for _, messageInfo := range messagesInfo { + if progress.shouldStop() { + break + } + + if seqSetSize != 0 && (seqSetSize+messageInfo.size) > imapMaxFetchSize { + log.WithField("mailbox", rule.SourceMailbox.Name).WithField("seq", seqSet).WithField("size", seqSetSize).Debug("Fetching messages") + + p.exportMessages(rule, progress, ch, seqSet, uidToID) + + seqSet = &imap.SeqSet{} + seqSetSize = 0 + uidToID = map[uint32]string{} + } + + seqSet.AddNum(messageInfo.uid) + seqSetSize += messageInfo.size + uidToID[messageInfo.uid] = messageInfo.id + } +} + +func (p *IMAPProvider) exportMessages(rule *Rule, progress *Progress, ch chan<- Message, seqSet *imap.SeqSet, uidToID map[uint32]string) { + section := &imap.BodySectionName{} + items := []imap.FetchItem{imap.FetchUid, imap.FetchFlags, section.FetchItem()} + + processMessageCallback := func(imapMessage *imap.Message) { + id, ok := uidToID[imapMessage.Uid] + + // Sometimes, server sends not requested messages. + if !ok { + log.WithField("uid", imapMessage.Uid).Warning("Message skipped: not requested") + return + } + + // Sometimes, server sends message twice, once with body and once without it. + bodyReader := imapMessage.GetBody(section) + if bodyReader == nil { + log.WithField("uid", imapMessage.Uid).Warning("Message skipped: no body") + return + } + + body, err := ioutil.ReadAll(bodyReader) + progress.messageExported(id, body, err) + if err == nil { + msg := p.exportMessage(rule, id, imapMessage, body) + ch <- msg + } + } + + progress.callWrap(func() error { + return p.uidFetch(seqSet, items, processMessageCallback) + }) +} + +func (p *IMAPProvider) exportMessage(rule *Rule, id string, imapMessage *imap.Message, body []byte) Message { + unread := true + for _, flag := range imapMessage.Flags { + if flag == imap.SeenFlag { + unread = false + } + } + + return Message{ + ID: id, + Unread: unread, + Body: body, + Source: rule.SourceMailbox, + Targets: rule.TargetMailboxes, + } +} diff --git a/internal/transfer/provider_imap_utils.go b/internal/transfer/provider_imap_utils.go new file mode 100644 index 00000000..364e9ecc --- /dev/null +++ b/internal/transfer/provider_imap_utils.go @@ -0,0 +1,236 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +import ( + "net" + "time" + + imapID "github.com/ProtonMail/go-imap-id" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/emersion/go-imap" + imapClient "github.com/emersion/go-imap/client" + sasl "github.com/emersion/go-sasl" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" +) + +const ( + imapDialTimeout = 5 * time.Second + imapRetries = 10 + imapReconnectTimeout = 30 * time.Minute + imapReconnectSleep = time.Minute +) + +type imapErrorLogger struct { + log *logrus.Entry +} + +func (l *imapErrorLogger) Printf(f string, v ...interface{}) { + l.log.Errorf(f, v...) +} + +func (l *imapErrorLogger) Println(v ...interface{}) { + l.log.Errorln(v...) +} + +type imapDebugLogger struct { //nolint[unused] + log *logrus.Entry +} + +func (l *imapDebugLogger) Write(data []byte) (int, error) { + l.log.Trace(string(data)) + return len(data), nil +} + +func (p *IMAPProvider) ensureConnection(callback func() error) error { + var callErr error + for i := 1; i <= imapRetries; i++ { + callErr = callback() + if callErr == nil { + return nil + } + + log.WithField("attempt", i).WithError(callErr).Warning("Call failed, trying reconnect") + err := p.tryReconnect() + if err != nil { + return err + } + } + return errors.Wrap(callErr, "too many retries") +} + +func (p *IMAPProvider) tryReconnect() error { + start := time.Now() + var previousErr error + for { + if time.Since(start) > imapReconnectTimeout { + return previousErr + } + + err := pmapi.CheckConnection() + if err != nil { + time.Sleep(imapReconnectSleep) + previousErr = err + continue + } + + err = p.reauth() + if err != nil { + time.Sleep(imapReconnectSleep) + previousErr = err + continue + } + + break + } + return nil +} + +func (p *IMAPProvider) reauth() error { + if _, err := p.client.Capability(); err != nil { + state := p.client.State() + log.WithField("addr", p.addr).WithField("state", state).WithError(err).Debug("Reconnecting") + p.client = nil + } + + return p.auth() +} + +func (p *IMAPProvider) auth() error { //nolint[funlen] + log := log.WithField("addr", p.addr) + + log.Info("Connecting to server") + + if _, err := net.DialTimeout("tcp", p.addr, imapDialTimeout); err != nil { + return errors.Wrap(err, "failed to dial server") + } + + client, err := imapClient.DialTLS(p.addr, nil) + if err != nil { + return errors.Wrap(err, "failed to connect to server") + } + client.ErrorLog = &imapErrorLogger{logrus.WithField("pkg", "imap-client")} + // Logrus have Writer helper but it fails for big messages because of + // bufio.MaxScanTokenSize limit. + // This spams a lot, uncomment once needed during development. + //client.SetDebug(&imapDebugLogger{logrus.WithField("pkg", "imap-client")}) + p.client = client + + log.Info("Connected") + + if (p.client.State() & imap.AuthenticatedState) == imap.AuthenticatedState { + return nil + } + + capability, err := p.client.Capability() + log.WithField("capability", capability).WithError(err).Debug("Server capability") + if err != nil { + return errors.Wrap(err, "failed to get capabilities") + } + + // SASL AUTH PLAIN + if ok, _ := p.client.SupportAuth("PLAIN"); p.client.State() == imap.NotAuthenticatedState && ok { + log.Debug("Trying plain auth") + authPlain := sasl.NewPlainClient("", p.username, p.password) + if err = p.client.Authenticate(authPlain); err != nil { + return errors.Wrap(err, "plain auth failed") + } + } + + // LOGIN: if the server reports the IMAP4rev1 capability then it is standards conformant and must support login. + if ok, _ := p.client.Support("IMAP4rev1"); p.client.State() == imap.NotAuthenticatedState && ok { + log.Debug("Trying login") + if err = p.client.Login(p.username, p.password); err != nil { + return errors.Wrap(err, "login failed") + } + } + + if p.client.State() == imap.NotAuthenticatedState { + return errors.New("unknown auth method") + } + + log.Info("Logged in") + + idClient := imapID.NewClient(p.client) + if ok, err := idClient.SupportID(); err == nil && ok { + serverID, err := idClient.ID(imapID.ID{ + imapID.FieldName: "ImportExport", + imapID.FieldVersion: "beta", + }) + log.WithField("ID", serverID).WithError(err).Debug("Server info") + } + + return err +} + +func (p *IMAPProvider) list() (mailboxes []*imap.MailboxInfo, err error) { + err = p.ensureConnection(func() error { + mailboxesCh := make(chan *imap.MailboxInfo) + doneCh := make(chan error) + + go func() { + doneCh <- p.client.List("", "*", mailboxesCh) + }() + + for mailbox := range mailboxesCh { + mailboxes = append(mailboxes, mailbox) + } + + return <-doneCh + }) + return +} + +func (p *IMAPProvider) selectIn(mailboxName string) (mailbox *imap.MailboxStatus, err error) { + err = p.ensureConnection(func() error { + mailbox, err = p.client.Select(mailboxName, true) + return err + }) + return +} + +func (p *IMAPProvider) fetch(seqSet *imap.SeqSet, items []imap.FetchItem, processMessageCallback func(m *imap.Message)) error { + return p.fetchHelper(false, seqSet, items, processMessageCallback) +} + +func (p *IMAPProvider) uidFetch(seqSet *imap.SeqSet, items []imap.FetchItem, processMessageCallback func(m *imap.Message)) error { + return p.fetchHelper(true, seqSet, items, processMessageCallback) +} + +func (p *IMAPProvider) fetchHelper(uid bool, seqSet *imap.SeqSet, items []imap.FetchItem, processMessageCallback func(m *imap.Message)) error { + return p.ensureConnection(func() error { + messagesCh := make(chan *imap.Message) + doneCh := make(chan error) + + go func() { + if uid { + doneCh <- p.client.UidFetch(seqSet, items, messagesCh) + } else { + doneCh <- p.client.Fetch(seqSet, items, messagesCh) + } + }() + + for message := range messagesCh { + processMessageCallback(message) + } + + err := <-doneCh + return err + }) +} diff --git a/internal/transfer/provider_local.go b/internal/transfer/provider_local.go new file mode 100644 index 00000000..4b52064d --- /dev/null +++ b/internal/transfer/provider_local.go @@ -0,0 +1,68 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +// LocalProvider implements import from local EML and MBOX file structure. +type LocalProvider struct { + root string + emlProvider *EMLProvider + mboxProvider *MBOXProvider +} + +func NewLocalProvider(root string) *LocalProvider { + return &LocalProvider{ + root: root, + emlProvider: NewEMLProvider(root), + mboxProvider: NewMBOXProvider(root), + } +} + +// ID is used for generating transfer ID by combining source and target ID. +// We want to keep the same rules for import from or export to local files +// no matter exact path, therefore it returns constant. +// The same as EML and MBOX. +func (p *LocalProvider) ID() string { + return "local" //nolint[goconst] +} + +// Mailboxes returns all available folder names from root of EML and MBOX files. +func (p *LocalProvider) Mailboxes(includeEmpty, includeAllMail bool) ([]Mailbox, error) { + mailboxes, err := p.emlProvider.Mailboxes(includeEmpty, includeAllMail) + if err != nil { + return nil, err + } + + mboxMailboxes, err := p.mboxProvider.Mailboxes(includeEmpty, includeAllMail) + if err != nil { + return nil, err + } + + for _, mboxMailbox := range mboxMailboxes { + found := false + for _, mailboxes := range mailboxes { + if mboxMailbox.Name == mailboxes.Name { + found = true + break + } + } + if !found { + mailboxes = append(mailboxes, mboxMailbox) + } + } + return mailboxes, nil +} diff --git a/internal/transfer/provider_local_source.go b/internal/transfer/provider_local_source.go new file mode 100644 index 00000000..e3549fe8 --- /dev/null +++ b/internal/transfer/provider_local_source.go @@ -0,0 +1,42 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +import ( + "sync" +) + +// TransferTo exports messages based on rules to channel. +func (p *LocalProvider) TransferTo(rules transferRules, progress *Progress, ch chan<- Message) { + log.Info("Started transfer from EML and MBOX to channel") + defer log.Info("Finished transfer from EML and MBOX to channel") + + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + p.emlProvider.TransferTo(rules, progress, ch) + }() + go func() { + defer wg.Done() + p.mboxProvider.TransferTo(rules, progress, ch) + }() + + wg.Wait() +} diff --git a/internal/transfer/provider_local_test.go b/internal/transfer/provider_local_test.go new file mode 100644 index 00000000..8cd9093b --- /dev/null +++ b/internal/transfer/provider_local_test.go @@ -0,0 +1,77 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +import ( + "fmt" + "testing" + + r "github.com/stretchr/testify/require" +) + +func newTestLocalProvider(path string) *LocalProvider { + if path == "" { + path = "testdata/emlmbox" + } + return NewLocalProvider(path) +} + +func TestLocalProviderMailboxes(t *testing.T) { + provider := newTestLocalProvider("") + + tests := []struct { + includeEmpty bool + wantMailboxes []Mailbox + }{ + {true, []Mailbox{ + {Name: "Foo"}, + {Name: "emlmbox"}, + {Name: "Inbox"}, + }}, + {false, []Mailbox{ + {Name: "Foo"}, + {Name: "Inbox"}, + }}, + } + for _, tc := range tests { + tc := tc + t.Run(fmt.Sprintf("%v", tc.includeEmpty), func(t *testing.T) { + mailboxes, err := provider.Mailboxes(tc.includeEmpty, false) + r.NoError(t, err) + r.Equal(t, tc.wantMailboxes, mailboxes) + }) + } +} + +func TestLocalProviderTransferTo(t *testing.T) { + provider := newTestLocalProvider("") + + rules, rulesClose := newTestRules(t) + defer rulesClose() + setupEMLMBOXRules(rules) + + testTransferTo(t, rules, provider, []string{ + "Foo/msg.eml", + "Inbox.mbox:1", + }) +} + +func setupEMLMBOXRules(rules transferRules) { + _ = rules.setRule(Mailbox{Name: "Inbox"}, []Mailbox{{Name: "Inbox"}}, 0, 0) + _ = rules.setRule(Mailbox{Name: "Foo"}, []Mailbox{{Name: "Foo"}}, 0, 0) +} diff --git a/internal/transfer/provider_mbox.go b/internal/transfer/provider_mbox.go new file mode 100644 index 00000000..0156fbc1 --- /dev/null +++ b/internal/transfer/provider_mbox.go @@ -0,0 +1,66 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +import ( + "path/filepath" + "strings" +) + +// MBOXProvider implements import and export to/from MBOX structure. +type MBOXProvider struct { + root string +} + +func NewMBOXProvider(root string) *MBOXProvider { + return &MBOXProvider{ + root: root, + } +} + +// ID is used for generating transfer ID by combining source and target ID. +// We want to keep the same rules for import from or export to local files +// no matter exact path, therefore it returns constant. The same as EML. +func (p *MBOXProvider) ID() string { + return "local" //nolint[goconst] +} + +// Mailboxes returns all available folder names from root of EML files. +// In case the same folder name is used more than once (for example root/a/foo +// and root/b/foo), it's treated as the same folder. +func (p *MBOXProvider) Mailboxes(includeEmpty, includeAllMail bool) ([]Mailbox, error) { + filePaths, err := getFilePathsWithSuffix(p.root, "mbox") + if err != nil { + return nil, err + } + + mailboxes := []Mailbox{} + for _, filePath := range filePaths { + fileName := filepath.Base(filePath) + mailboxName := strings.TrimSuffix(fileName, ".mbox") + + mailboxes = append(mailboxes, Mailbox{ + ID: "", + Name: mailboxName, + Color: "", + IsExclusive: false, + }) + } + + return mailboxes, nil +} diff --git a/internal/transfer/provider_mbox_source.go b/internal/transfer/provider_mbox_source.go new file mode 100644 index 00000000..68491893 --- /dev/null +++ b/internal/transfer/provider_mbox_source.go @@ -0,0 +1,183 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +import ( + "fmt" + "io" + "io/ioutil" + "os" + "path/filepath" + "strings" + + "github.com/emersion/go-mbox" + "github.com/pkg/errors" +) + +// TransferTo exports messages based on rules to channel. +func (p *MBOXProvider) TransferTo(rules transferRules, progress *Progress, ch chan<- Message) { + log.Info("Started transfer from MBOX to channel") + defer log.Info("Finished transfer from MBOX to channel") + + filePathsPerFolder, err := p.getFilePathsPerFolder(rules) + if err != nil { + progress.fatal(err) + return + } + + for folderName, filePaths := range filePathsPerFolder { + // No error guaranteed by getFilePathsPerFolder. + rule, _ := rules.getRuleBySourceMailboxName(folderName) + for _, filePath := range filePaths { + if progress.shouldStop() { + break + } + p.updateCount(rule, progress, filePath) + } + } + + for folderName, filePaths := range filePathsPerFolder { + // No error guaranteed by getFilePathsPerFolder. + rule, _ := rules.getRuleBySourceMailboxName(folderName) + log.WithField("rule", rule).Debug("Processing rule") + for _, filePath := range filePaths { + if progress.shouldStop() { + break + } + p.transferTo(rule, progress, ch, filePath) + } + } +} + +func (p *MBOXProvider) getFilePathsPerFolder(rules transferRules) (map[string][]string, error) { + filePaths, err := getFilePathsWithSuffix(p.root, ".mbox") + if err != nil { + return nil, err + } + + filePathsMap := map[string][]string{} + for _, filePath := range filePaths { + fileName := filepath.Base(filePath) + folder := strings.TrimSuffix(fileName, ".mbox") + _, err := rules.getRuleBySourceMailboxName(folder) + if err != nil { + log.WithField("msg", filePath).Trace("Mailbox skipped due to folder name") + continue + } + + filePathsMap[folder] = append(filePathsMap[folder], filePath) + } + return filePathsMap, nil +} + +func (p *MBOXProvider) updateCount(rule *Rule, progress *Progress, filePath string) { + mboxReader := p.openMbox(progress, filePath) + if mboxReader == nil { + return + } + + count := 0 + for { + _, err := mboxReader.NextMessage() + if err != nil { + break + } + count++ + } + progress.updateCount(rule.SourceMailbox.Name, uint(count)) +} + +func (p *MBOXProvider) transferTo(rule *Rule, progress *Progress, ch chan<- Message, filePath string) { + mboxReader := p.openMbox(progress, filePath) + if mboxReader == nil { + return + } + + index := 0 + count := 0 + for { + if progress.shouldStop() { + break + } + + index++ + id := fmt.Sprintf("%s:%d", filePath, index) + + msgReader, err := mboxReader.NextMessage() + if err == io.EOF { + break + } else if err != nil { + progress.fatal(err) + break + } + + msg, err := p.exportMessage(rule, id, msgReader) + + // Read and check time in body only if the rule specifies it + // to not waste energy. + if err == nil && rule.HasTimeLimit() { + msgTime, msgTimeErr := getMessageTime(msg.Body) + if msgTimeErr != nil { + err = msgTimeErr + } else if !rule.isTimeInRange(msgTime) { + log.WithField("msg", id).Debug("Message skipped due to time") + continue + } + } + + // Counting only messages filtered by time to update count to correct total. + count++ + + // addMessage is called after time check to not report message + // which should not be exported but any error from reading body + // or parsing time is reported as an error. + progress.addMessage(id, rule) + progress.messageExported(id, msg.Body, err) + if err == nil { + ch <- msg + } + } + progress.updateCount(rule.SourceMailbox.Name, uint(count)) +} + +func (p *MBOXProvider) exportMessage(rule *Rule, id string, msgReader io.Reader) (Message, error) { + body, err := ioutil.ReadAll(msgReader) + if err != nil { + return Message{}, errors.Wrap(err, "failed to read message") + } + + return Message{ + ID: id, + Unread: false, + Body: body, + Source: rule.SourceMailbox, + Targets: rule.TargetMailboxes, + }, nil +} + +func (p *MBOXProvider) openMbox(progress *Progress, mboxPath string) *mbox.Reader { + mboxPath = filepath.Join(p.root, mboxPath) + mboxFile, err := os.Open(mboxPath) //nolint[gosec] + if os.IsNotExist(err) { + return nil + } else if err != nil { + progress.fatal(err) + return nil + } + return mbox.NewReader(mboxFile) +} diff --git a/internal/transfer/provider_mbox_target.go b/internal/transfer/provider_mbox_target.go new file mode 100644 index 00000000..610f8cf2 --- /dev/null +++ b/internal/transfer/provider_mbox_target.go @@ -0,0 +1,97 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +import ( + "os" + "path/filepath" + "strings" + "time" + + "github.com/emersion/go-mbox" + "github.com/hashicorp/go-multierror" +) + +// DefaultMailboxes returns the default mailboxes for default rules if no other is found. +func (p *MBOXProvider) DefaultMailboxes(sourceMailbox Mailbox) []Mailbox { + return []Mailbox{{ + Name: sourceMailbox.Name, + }} +} + +// CreateMailbox does nothing. Files are created dynamically during the import. +func (p *MBOXProvider) CreateMailbox(mailbox Mailbox) (Mailbox, error) { + return mailbox, nil +} + +// TransferFrom imports messages from channel. +func (p *MBOXProvider) TransferFrom(rules transferRules, progress *Progress, ch <-chan Message) { + log.Info("Started transfer from channel to MBOX") + defer log.Info("Finished transfer from channel to MBOX") + + for msg := range ch { + for progress.shouldStop() { + break + } + + err := p.writeMessage(msg) + progress.messageImported(msg.ID, "", err) + } +} + +func (p *MBOXProvider) writeMessage(msg Message) error { + var multiErr error + for _, mailbox := range msg.Targets { + mboxName := filepath.Base(mailbox.Name) + if !strings.HasSuffix(mboxName, ".mbox") { + mboxName += ".mbox" + } + + mboxPath := filepath.Join(p.root, mboxName) + mboxFile, err := os.OpenFile(mboxPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + multiErr = multierror.Append(multiErr, err) + continue + } + + msgFrom := "" + msgTime := time.Now() + if header, err := getMessageHeader(msg.Body); err == nil { + if date, err := header.Date(); err == nil { + msgTime = date + } + if addresses, err := header.AddressList("from"); err == nil && len(addresses) > 0 { + msgFrom = addresses[0].Address + } + } + + mboxWriter := mbox.NewWriter(mboxFile) + messageWriter, err := mboxWriter.CreateMessage(msgFrom, msgTime) + if err != nil { + multiErr = multierror.Append(multiErr, err) + continue + } + + _, err = messageWriter.Write(msg.Body) + if err != nil { + multiErr = multierror.Append(multiErr, err) + continue + } + } + return multiErr +} diff --git a/internal/transfer/provider_mbox_test.go b/internal/transfer/provider_mbox_test.go new file mode 100644 index 00000000..d9145644 --- /dev/null +++ b/internal/transfer/provider_mbox_test.go @@ -0,0 +1,125 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +import ( + "fmt" + "io/ioutil" + "os" + "testing" + "time" + + r "github.com/stretchr/testify/require" +) + +func newTestMBOXProvider(path string) *MBOXProvider { + if path == "" { + path = "testdata/mbox" + } + return NewMBOXProvider(path) +} + +func TestMBOXProviderMailboxes(t *testing.T) { + provider := newTestMBOXProvider("") + + tests := []struct { + includeEmpty bool + wantMailboxes []Mailbox + }{ + {true, []Mailbox{ + {Name: "Foo"}, + {Name: "Inbox"}, + }}, + {false, []Mailbox{ + {Name: "Foo"}, + {Name: "Inbox"}, + }}, + } + for _, tc := range tests { + tc := tc + t.Run(fmt.Sprintf("%v", tc.includeEmpty), func(t *testing.T) { + mailboxes, err := provider.Mailboxes(tc.includeEmpty, false) + r.NoError(t, err) + r.Equal(t, tc.wantMailboxes, mailboxes) + }) + } +} + +func TestMBOXProviderTransferTo(t *testing.T) { + provider := newTestMBOXProvider("") + + rules, rulesClose := newTestRules(t) + defer rulesClose() + setupMBOXRules(rules) + + testTransferTo(t, rules, provider, []string{ + "Foo.mbox:1", + "Inbox.mbox:1", + }) +} + +func TestMBOXProviderTransferFrom(t *testing.T) { + dir, err := ioutil.TempDir("", "eml") + r.NoError(t, err) + defer os.RemoveAll(dir) //nolint[errcheck] + + provider := newTestMBOXProvider(dir) + + rules, rulesClose := newTestRules(t) + defer rulesClose() + setupMBOXRules(rules) + + testTransferFrom(t, rules, provider, []Message{ + {ID: "Foo.mbox:1", Body: getTestMsgBody("msg"), Targets: []Mailbox{{Name: "Foo"}}}, + }) + + checkMBOXFileStructure(t, dir, []string{ + "Foo.mbox", + }) +} + +func TestMBOXProviderTransferFromTo(t *testing.T) { + dir, err := ioutil.TempDir("", "eml") + r.NoError(t, err) + defer os.RemoveAll(dir) //nolint[errcheck] + + source := newTestMBOXProvider("") + target := newTestMBOXProvider(dir) + + rules, rulesClose := newTestRules(t) + defer rulesClose() + setupEMLRules(rules) + + testTransferFromTo(t, rules, source, target, 5*time.Second) + + checkMBOXFileStructure(t, dir, []string{ + "Foo.mbox", + "Inbox.mbox", + }) +} + +func setupMBOXRules(rules transferRules) { + _ = rules.setRule(Mailbox{Name: "Inbox"}, []Mailbox{{Name: "Inbox"}}, 0, 0) + _ = rules.setRule(Mailbox{Name: "Foo"}, []Mailbox{{Name: "Foo"}}, 0, 0) +} + +func checkMBOXFileStructure(t *testing.T, root string, expectedFiles []string) { + files, err := getFilePathsWithSuffix(root, ".mbox") + r.NoError(t, err) + r.Equal(t, expectedFiles, files) +} diff --git a/internal/transfer/provider_pmapi.go b/internal/transfer/provider_pmapi.go new file mode 100644 index 00000000..f6f14e27 --- /dev/null +++ b/internal/transfer/provider_pmapi.go @@ -0,0 +1,140 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +import ( + "sort" + + "github.com/ProtonMail/gopenpgp/crypto" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/pkg/errors" +) + +// PMAPIProvider implements import and export to/from ProtonMail server. +type PMAPIProvider struct { + clientManager ClientManager + userID string + addressID string + keyRing *crypto.KeyRing +} + +// NewPMAPIProvider returns new PMAPIProvider. +func NewPMAPIProvider(clientManager ClientManager, userID, addressID string) (*PMAPIProvider, error) { + keyRing, err := clientManager.GetClient(userID).KeyRingForAddressID(addressID) + if err != nil { + return nil, errors.Wrap(err, "failed to get key ring") + } + + return &PMAPIProvider{ + clientManager: clientManager, + userID: userID, + addressID: addressID, + keyRing: keyRing, + }, nil +} + +func (p *PMAPIProvider) client() pmapi.Client { + return p.clientManager.GetClient(p.userID) +} + +// ID returns identifier of current setup of PMAPI provider. +// Identification is unique per user. +func (p *PMAPIProvider) ID() string { + return p.userID +} + +// Mailboxes returns all available labels in ProtonMail account. +func (p *PMAPIProvider) Mailboxes(includeEmpty, includeAllMail bool) ([]Mailbox, error) { + labels, err := p.client().ListLabels() + if err != nil { + return nil, err + } + sortedLabels := byFoldersLabels(labels) + sort.Sort(sortedLabels) + + emptyLabelsMap := map[string]bool{} + if !includeEmpty { + messagesCounts, err := p.client().CountMessages(p.addressID) + if err != nil { + return nil, err + } + for _, messagesCount := range messagesCounts { + if messagesCount.Total == 0 { + emptyLabelsMap[messagesCount.LabelID] = true + } + } + } + + mailboxes := getSystemMailboxes(includeAllMail) + for _, label := range sortedLabels { + if !includeEmpty && emptyLabelsMap[label.ID] { + continue + } + + mailboxes = append(mailboxes, Mailbox{ + ID: label.ID, + Name: label.Name, + Color: label.Color, + IsExclusive: label.Exclusive == 1, + }) + } + return mailboxes, nil +} + +func getSystemMailboxes(includeAllMail bool) []Mailbox { + mailboxes := []Mailbox{ + {ID: pmapi.InboxLabel, Name: "Inbox", IsExclusive: true}, + {ID: pmapi.DraftLabel, Name: "Drafts", IsExclusive: true}, + {ID: pmapi.SentLabel, Name: "Sent", IsExclusive: true}, + {ID: pmapi.StarredLabel, Name: "Starred", IsExclusive: true}, + {ID: pmapi.ArchiveLabel, Name: "Archive", IsExclusive: true}, + {ID: pmapi.SpamLabel, Name: "Spam", IsExclusive: true}, + {ID: pmapi.TrashLabel, Name: "Trash", IsExclusive: true}, + } + + if includeAllMail { + mailboxes = append(mailboxes, Mailbox{ + ID: pmapi.AllMailLabel, + Name: "All Mail", + IsExclusive: true, + }) + } + + return mailboxes +} + +type byFoldersLabels []*pmapi.Label + +func (l byFoldersLabels) Len() int { + return len(l) +} + +func (l byFoldersLabels) Swap(i, j int) { + l[i], l[j] = l[j], l[i] +} + +// Less sorts first folders, then labels, by user order. +func (l byFoldersLabels) Less(i, j int) bool { + if l[i].Exclusive == 1 && l[j].Exclusive == 0 { + return true + } + if l[i].Exclusive == 0 && l[j].Exclusive == 1 { + return false + } + return l[i].Order < l[j].Order +} diff --git a/internal/transfer/provider_pmapi_source.go b/internal/transfer/provider_pmapi_source.go new file mode 100644 index 00000000..240a4a84 --- /dev/null +++ b/internal/transfer/provider_pmapi_source.go @@ -0,0 +1,161 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +import ( + "fmt" + + pkgMessage "github.com/ProtonMail/proton-bridge/pkg/message" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/pkg/errors" +) + +const pmapiListPageSize = 150 + +// TransferTo exports messages based on rules to channel. +func (p *PMAPIProvider) TransferTo(rules transferRules, progress *Progress, ch chan<- Message) { + log.Info("Started transfer from PMAPI to channel") + defer log.Info("Finished transfer from PMAPI to channel") + + go p.loadCounts(rules, progress) + + for rule := range rules.iterateActiveRules() { + p.transferTo(rule, progress, ch, rules.skipEncryptedMessages) + } +} + +func (p *PMAPIProvider) loadCounts(rules transferRules, progress *Progress) { + for rule := range rules.iterateActiveRules() { + if progress.shouldStop() { + break + } + + rule := rule + progress.callWrap(func() error { + _, total, err := p.listMessages(&pmapi.MessagesFilter{ + LabelID: rule.SourceMailbox.ID, + Begin: rule.FromTime, + End: rule.ToTime, + Limit: 0, + }) + if err != nil { + log.WithError(err).Warning("Problem to load counts") + return err + } + progress.updateCount(rule.SourceMailbox.Name, uint(total)) + return nil + }) + } +} + +func (p *PMAPIProvider) transferTo(rule *Rule, progress *Progress, ch chan<- Message, skipEncryptedMessages bool) { + nextID := "" + for { + if progress.shouldStop() { + break + } + + isLastPage := true + + progress.callWrap(func() error { + desc := false + pmapiMessages, count, err := p.listMessages(&pmapi.MessagesFilter{ + LabelID: rule.SourceMailbox.ID, + Begin: rule.FromTime, + End: rule.ToTime, + BeginID: nextID, + PageSize: pmapiListPageSize, + Page: 0, + Sort: "ID", + Desc: &desc, + }) + if err != nil { + return err + } + log.WithField("label", rule.SourceMailbox.ID).WithField("next", nextID).WithField("count", count).Debug("Listing messages") + + isLastPage = len(pmapiMessages) < pmapiListPageSize + + // The first ID is the last one from the last page (= do not export twice the same one). + if nextID != "" { + pmapiMessages = pmapiMessages[1:] + } + + for _, pmapiMessage := range pmapiMessages { + if progress.shouldStop() { + break + } + + msgID := fmt.Sprintf("%s_%s", rule.SourceMailbox.ID, pmapiMessage.ID) + progress.addMessage(msgID, rule) + msg, err := p.exportMessage(rule, progress, pmapiMessage.ID, msgID, skipEncryptedMessages) + progress.messageExported(msgID, msg.Body, err) + if err == nil { + ch <- msg + } + } + + if !isLastPage { + nextID = pmapiMessages[len(pmapiMessages)-1].ID + } + + return nil + }) + + if isLastPage { + break + } + } +} + +func (p *PMAPIProvider) exportMessage(rule *Rule, progress *Progress, pmapiMsgID, msgID string, skipEncryptedMessages bool) (Message, error) { + var msg *pmapi.Message + progress.callWrap(func() error { + var err error + msg, err = p.getMessage(pmapiMsgID) + return err + }) + + msgBuilder := pkgMessage.NewBuilder(p.client(), msg) + msgBuilder.EncryptedToHTML = false + _, body, err := msgBuilder.BuildMessage() + if err != nil { + return Message{ + Body: body, // Keep body to show details about the message to user. + }, errors.Wrap(err, "failed to build message") + } + + if !msgBuilder.SuccessfullyDecrypted() && skipEncryptedMessages { + return Message{ + Body: body, // Keep body to show details about the message to user. + }, errors.New("skipping encrypted message") + } + + unread := false + if msg.Unread == 1 { + unread = true + } + + return Message{ + ID: msgID, + Unread: unread, + Body: body, + Source: rule.SourceMailbox, + Targets: rule.TargetMailboxes, + }, nil +} diff --git a/internal/transfer/provider_pmapi_target.go b/internal/transfer/provider_pmapi_target.go new file mode 100644 index 00000000..1034caa4 --- /dev/null +++ b/internal/transfer/provider_pmapi_target.go @@ -0,0 +1,220 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +import ( + "bytes" + "fmt" + "io" + "io/ioutil" + + pkgMessage "github.com/ProtonMail/proton-bridge/pkg/message" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/pkg/errors" +) + +// DefaultMailboxes returns the default mailboxes for default rules if no other is found. +func (p *PMAPIProvider) DefaultMailboxes(_ Mailbox) []Mailbox { + return []Mailbox{{ + ID: pmapi.ArchiveLabel, + Name: "Archive", + IsExclusive: true, + }} +} + +// CreateMailbox creates label in ProtonMail account. +func (p *PMAPIProvider) CreateMailbox(mailbox Mailbox) (Mailbox, error) { + if mailbox.ID != "" { + return Mailbox{}, errors.New("mailbox is already created") + } + + exclusive := 0 + if mailbox.IsExclusive { + exclusive = 1 + } + + label, err := p.client().CreateLabel(&pmapi.Label{ + Name: mailbox.Name, + Color: mailbox.Color, + Exclusive: exclusive, + Type: pmapi.LabelTypeMailbox, + }) + if err != nil { + return Mailbox{}, errors.Wrap(err, fmt.Sprintf("failed to create mailbox %s", mailbox.Name)) + } + mailbox.ID = label.ID + return mailbox, nil +} + +// TransferFrom imports messages from channel. +func (p *PMAPIProvider) TransferFrom(rules transferRules, progress *Progress, ch <-chan Message) { + log.Info("Started transfer from channel to PMAPI") + defer log.Info("Finished transfer from channel to PMAPI") + + for msg := range ch { + for progress.shouldStop() { + break + } + + var importedID string + var err error + if p.isMessageDraft(msg) { + importedID, err = p.importDraft(msg, rules.globalMailbox) + } else { + importedID, err = p.importMessage(msg, rules.globalMailbox) + } + progress.messageImported(msg.ID, importedID, err) + } +} + +func (p *PMAPIProvider) isMessageDraft(msg Message) bool { + for _, target := range msg.Targets { + if target.ID == pmapi.DraftLabel { + return true + } + } + return false +} + +func (p *PMAPIProvider) importDraft(msg Message, globalMailbox *Mailbox) (string, error) { + message, attachmentReaders, err := p.parseMessage(msg) + if err != nil { + return "", errors.Wrap(err, "failed to parse message") + } + + if err := message.Encrypt(p.keyRing, nil); err != nil { + return "", errors.Wrap(err, "failed to encrypt draft") + } + + if globalMailbox != nil { + message.LabelIDs = append(message.LabelIDs, globalMailbox.ID) + } + + attachments := message.Attachments + message.Attachments = nil + + draft, err := p.createDraft(message, "", pmapi.DraftActionReply) + if err != nil { + return "", errors.Wrap(err, "failed to create draft") + } + + for idx, attachment := range attachments { + attachment.MessageID = draft.ID + attachmentBody, _ := ioutil.ReadAll(attachmentReaders[idx]) + + r := bytes.NewReader(attachmentBody) + sigReader, err := attachment.DetachedSign(p.keyRing, r) + if err != nil { + return "", errors.Wrap(err, "failed to sign attachment") + } + + r = bytes.NewReader(attachmentBody) + encReader, err := attachment.Encrypt(p.keyRing, r) + if err != nil { + return "", errors.Wrap(err, "failed to encrypt attachment") + } + + _, err = p.createAttachment(attachment, encReader, sigReader) + if err != nil { + return "", errors.Wrap(err, "failed to create attachment") + } + } + + return draft.ID, nil +} + +func (p *PMAPIProvider) importMessage(msg Message, globalMailbox *Mailbox) (string, error) { + message, attachmentReaders, err := p.parseMessage(msg) + if err != nil { + return "", errors.Wrap(err, "failed to parse message") + } + + body, err := p.encryptMessage(message, attachmentReaders) + if err != nil { + return "", errors.Wrap(err, "failed to encrypt message") + } + + unread := 0 + if msg.Unread { + unread = 1 + } + + labelIDs := []string{} + for _, target := range msg.Targets { + // Frontend should not set All Mail to Rules, but to be sure... + if target.ID != pmapi.AllMailLabel { + labelIDs = append(labelIDs, target.ID) + } + } + if globalMailbox != nil { + labelIDs = append(labelIDs, globalMailbox.ID) + } + + importMsgReq := &pmapi.ImportMsgReq{ + AddressID: p.addressID, + Body: body, + Unread: unread, + Time: message.Time, + Flags: computeMessageFlags(labelIDs), + LabelIDs: labelIDs, + } + + results, err := p.importRequest([]*pmapi.ImportMsgReq{importMsgReq}) + if err != nil { + return "", errors.Wrap(err, "failed to import messages") + } + if len(results) == 0 { + return "", errors.New("import ended with no result") + } + if results[0].Error != nil { + return "", errors.Wrap(results[0].Error, "failed to import message") + } + return results[0].MessageID, nil +} + +func (p *PMAPIProvider) parseMessage(msg Message) (*pmapi.Message, []io.Reader, error) { + message, _, _, attachmentReaders, err := pkgMessage.Parse(bytes.NewBuffer(msg.Body), "", "") + return message, attachmentReaders, err +} + +func (p *PMAPIProvider) encryptMessage(msg *pmapi.Message, attachmentReaders []io.Reader) ([]byte, error) { + if msg.MIMEType == pmapi.ContentTypeMultipartEncrypted { + return []byte(msg.Body), nil + } + return pkgMessage.BuildEncrypted(msg, attachmentReaders, p.keyRing) +} + +func computeMessageFlags(labels []string) (flag int64) { + for _, labelID := range labels { + switch labelID { + case pmapi.SentLabel: + flag = (flag | pmapi.FlagSent) + case pmapi.ArchiveLabel, pmapi.InboxLabel: + flag = (flag | pmapi.FlagReceived) + case pmapi.DraftLabel: + log.Error("Found draft target in non-draft import") + } + } + + // NOTE: if the labels are custom only + if flag == 0 { + flag = pmapi.FlagReceived + } + + return flag +} diff --git a/internal/transfer/provider_pmapi_test.go b/internal/transfer/provider_pmapi_test.go new file mode 100644 index 00000000..6d529fae --- /dev/null +++ b/internal/transfer/provider_pmapi_test.go @@ -0,0 +1,201 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +import ( + "bytes" + "fmt" + "testing" + "time" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + gomock "github.com/golang/mock/gomock" + r "github.com/stretchr/testify/require" +) + +func TestPMAPIProviderMailboxes(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + setupPMAPIClientExpectationForExport(&m) + provider, err := NewPMAPIProvider(m.clientManager, "user", "addressID") + r.NoError(t, err) + + tests := []struct { + includeEmpty bool + includeAllMail bool + wantMailboxes []Mailbox + }{ + {true, false, []Mailbox{ + {ID: "folder1", Name: "One", Color: "red", IsExclusive: true}, + {ID: "folder2", Name: "Two", Color: "orange", IsExclusive: true}, + {ID: "label2", Name: "Bar", Color: "green", IsExclusive: false}, + {ID: "label1", Name: "Foo", Color: "blue", IsExclusive: false}, + }}, + {false, true, []Mailbox{ + {ID: pmapi.AllMailLabel, Name: "All Mail", IsExclusive: true}, + {ID: "folder1", Name: "One", Color: "red", IsExclusive: true}, + {ID: "folder2", Name: "Two", Color: "orange", IsExclusive: true}, + {ID: "label1", Name: "Foo", Color: "blue", IsExclusive: false}, + }}, + } + for _, tc := range tests { + tc := tc + t.Run(fmt.Sprintf("%v-%v", tc.includeEmpty, tc.includeAllMail), func(t *testing.T) { + mailboxes, err := provider.Mailboxes(tc.includeEmpty, tc.includeAllMail) + r.NoError(t, err) + r.Equal(t, []Mailbox{ + {ID: pmapi.InboxLabel, Name: "Inbox", IsExclusive: true}, + {ID: pmapi.DraftLabel, Name: "Drafts", IsExclusive: true}, + {ID: pmapi.SentLabel, Name: "Sent", IsExclusive: true}, + {ID: pmapi.StarredLabel, Name: "Starred", IsExclusive: true}, + {ID: pmapi.ArchiveLabel, Name: "Archive", IsExclusive: true}, + {ID: pmapi.SpamLabel, Name: "Spam", IsExclusive: true}, + {ID: pmapi.TrashLabel, Name: "Trash", IsExclusive: true}, + }, mailboxes[:7]) + r.Equal(t, tc.wantMailboxes, mailboxes[7:]) + }) + } +} + +func TestPMAPIProviderTransferTo(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + setupPMAPIClientExpectationForExport(&m) + provider, err := NewPMAPIProvider(m.clientManager, "user", "addressID") + r.NoError(t, err) + + rules, rulesClose := newTestRules(t) + defer rulesClose() + setupPMAPIRules(rules) + + testTransferTo(t, rules, provider, []string{ + "0_msg1", + "0_msg2", + }) +} + +func TestPMAPIProviderTransferFrom(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + setupPMAPIClientExpectationForImport(&m) + provider, err := NewPMAPIProvider(m.clientManager, "user", "addressID") + r.NoError(t, err) + + rules, rulesClose := newTestRules(t) + defer rulesClose() + setupPMAPIRules(rules) + + testTransferFrom(t, rules, provider, []Message{ + {ID: "msg1", Body: getTestMsgBody("msg1"), Targets: []Mailbox{{ID: pmapi.InboxLabel}}}, + {ID: "msg2", Body: getTestMsgBody("msg2"), Targets: []Mailbox{{ID: pmapi.InboxLabel}}}, + }) +} + +func TestPMAPIProviderTransferFromDraft(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + setupPMAPIClientExpectationForImportDraft(&m) + provider, err := NewPMAPIProvider(m.clientManager, "user", "addressID") + r.NoError(t, err) + + rules, rulesClose := newTestRules(t) + defer rulesClose() + setupPMAPIRules(rules) + + testTransferFrom(t, rules, provider, []Message{ + {ID: "draft1", Body: getTestMsgBody("draft1"), Targets: []Mailbox{{ID: pmapi.DraftLabel}}}, + }) +} + +func TestPMAPIProviderTransferFromTo(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + setupPMAPIClientExpectationForExport(&m) + setupPMAPIClientExpectationForImport(&m) + + source, err := NewPMAPIProvider(m.clientManager, "user", "addressID") + r.NoError(t, err) + target, err := NewPMAPIProvider(m.clientManager, "user", "addressID") + r.NoError(t, err) + + rules, rulesClose := newTestRules(t) + defer rulesClose() + setupPMAPIRules(rules) + + testTransferFromTo(t, rules, source, target, 5*time.Second) +} + +func setupPMAPIRules(rules transferRules) { + _ = rules.setRule(Mailbox{ID: pmapi.InboxLabel}, []Mailbox{{ID: pmapi.InboxLabel}}, 0, 0) +} + +func setupPMAPIClientExpectationForExport(m *mocks) { + m.pmapiClient.EXPECT().KeyRingForAddressID(gomock.Any()).Return(m.keyring, nil).AnyTimes() + m.pmapiClient.EXPECT().ListLabels().Return([]*pmapi.Label{ + {ID: "label1", Name: "Foo", Color: "blue", Exclusive: 0, Order: 2}, + {ID: "label2", Name: "Bar", Color: "green", Exclusive: 0, Order: 1}, + {ID: "folder1", Name: "One", Color: "red", Exclusive: 1, Order: 1}, + {ID: "folder2", Name: "Two", Color: "orange", Exclusive: 1, Order: 2}, + }, nil).AnyTimes() + m.pmapiClient.EXPECT().CountMessages(gomock.Any()).Return([]*pmapi.MessagesCount{ + {LabelID: "label1", Total: 10}, + {LabelID: "label2", Total: 0}, + {LabelID: "folder1", Total: 20}, + }, nil).AnyTimes() + m.pmapiClient.EXPECT().ListMessages(gomock.Any()).Return([]*pmapi.Message{ + {ID: "msg1"}, + {ID: "msg2"}, + }, 2, nil).AnyTimes() + m.pmapiClient.EXPECT().GetMessage(gomock.Any()).DoAndReturn(func(msgID string) (*pmapi.Message, error) { + return &pmapi.Message{ + ID: msgID, + Body: string(getTestMsgBody(msgID)), + MIMEType: pmapi.ContentTypeMultipartMixed, + }, nil + }).AnyTimes() +} + +func setupPMAPIClientExpectationForImport(m *mocks) { + m.pmapiClient.EXPECT().KeyRingForAddressID(gomock.Any()).Return(m.keyring, nil).AnyTimes() + m.pmapiClient.EXPECT().Import(gomock.Any()).DoAndReturn(func(requests []*pmapi.ImportMsgReq) ([]*pmapi.ImportMsgRes, error) { + r.Equal(m.t, 1, len(requests)) + + request := requests[0] + for _, msgID := range []string{"msg1", "msg2"} { + if bytes.Contains(request.Body, []byte(msgID)) { + return []*pmapi.ImportMsgRes{{MessageID: msgID, Error: nil}}, nil + } + } + r.Fail(m.t, "No message found") + return nil, nil + }).Times(2) +} + +func setupPMAPIClientExpectationForImportDraft(m *mocks) { + m.pmapiClient.EXPECT().KeyRingForAddressID(gomock.Any()).Return(m.keyring, nil).AnyTimes() + m.pmapiClient.EXPECT().CreateDraft(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(msg *pmapi.Message, parentID string, action int) (*pmapi.Message, error) { + r.Equal(m.t, msg.Subject, "draft1") + msg.ID = "draft1" + return msg, nil + }) +} diff --git a/internal/transfer/provider_pmapi_utils.go b/internal/transfer/provider_pmapi_utils.go new file mode 100644 index 00000000..65bf8817 --- /dev/null +++ b/internal/transfer/provider_pmapi_utils.go @@ -0,0 +1,109 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +import ( + "io" + "time" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/pkg/errors" +) + +const ( + pmapiRetries = 10 + pmapiReconnectTimeout = 30 * time.Minute + pmapiReconnectSleep = time.Minute +) + +func (p *PMAPIProvider) ensureConnection(callback func() error) error { + var callErr error + for i := 1; i <= pmapiRetries; i++ { + callErr = callback() + if callErr == nil { + return nil + } + + log.WithField("attempt", i).WithError(callErr).Warning("Call failed, trying reconnect") + err := p.tryReconnect() + if err != nil { + return err + } + } + return errors.Wrap(callErr, "too many retries") +} + +func (p *PMAPIProvider) tryReconnect() error { + start := time.Now() + var previousErr error + for { + if time.Since(start) > pmapiReconnectTimeout { + return previousErr + } + + err := p.clientManager.CheckConnection() + if err != nil { + time.Sleep(pmapiReconnectSleep) + previousErr = err + continue + } + + break + } + return nil +} + +func (p *PMAPIProvider) listMessages(filter *pmapi.MessagesFilter) (messages []*pmapi.Message, count int, err error) { + err = p.ensureConnection(func() error { + messages, count, err = p.client().ListMessages(filter) + return err + }) + return +} + +func (p *PMAPIProvider) getMessage(msgID string) (message *pmapi.Message, err error) { + err = p.ensureConnection(func() error { + message, err = p.client().GetMessage(msgID) + return err + }) + return +} + +func (p *PMAPIProvider) importRequest(req []*pmapi.ImportMsgReq) (res []*pmapi.ImportMsgRes, err error) { + err = p.ensureConnection(func() error { + res, err = p.client().Import(req) + return err + }) + return +} + +func (p *PMAPIProvider) createDraft(message *pmapi.Message, parent string, action int) (draft *pmapi.Message, err error) { + err = p.ensureConnection(func() error { + draft, err = p.client().CreateDraft(message, parent, action) + return err + }) + return +} + +func (p *PMAPIProvider) createAttachment(att *pmapi.Attachment, r io.Reader, sig io.Reader) (created *pmapi.Attachment, err error) { + err = p.ensureConnection(func() error { + created, err = p.client().CreateAttachment(att, r, sig) + return err + }) + return +} diff --git a/internal/transfer/provider_test.go b/internal/transfer/provider_test.go new file mode 100644 index 00000000..1b58a14d --- /dev/null +++ b/internal/transfer/provider_test.go @@ -0,0 +1,111 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +import ( + "fmt" + "testing" + "time" + + a "github.com/stretchr/testify/assert" + r "github.com/stretchr/testify/require" +) + +func getTestMsgBody(subject string) []byte { + return []byte(fmt.Sprintf(`Subject: %s +From: Bridge Test +To: Bridge Test +Content-Type: multipart/mixed; boundary=c672b8d1ef56ed28ab87c3622c5114069bdd3ad7b8f9737498d0c01ecef0967a + +--c672b8d1ef56ed28ab87c3622c5114069bdd3ad7b8f9737498d0c01ecef0967a +Content-Disposition: inline +Content-Transfer-Encoding: 7bit +Content-Type: text/plain; charset=utf-8 + +hello + +--c672b8d1ef56ed28ab87c3622c5114069bdd3ad7b8f9737498d0c01ecef0967a-- +`, subject)) +} + +func testTransferTo(t *testing.T, rules transferRules, provider SourceProvider, expectedMessageIDs []string) { + progress := newProgress(log, nil) + drainProgressUpdateChannel(&progress) + + ch := make(chan Message) + go func() { + provider.TransferTo(rules, &progress, ch) + close(ch) + }() + + gotMessageIDs := []string{} + for msg := range ch { + gotMessageIDs = append(gotMessageIDs, msg.ID) + } + r.ElementsMatch(t, expectedMessageIDs, gotMessageIDs) + + r.Empty(t, progress.GetFailedMessages()) +} + +func testTransferFrom(t *testing.T, rules transferRules, provider TargetProvider, messages []Message) { + progress := newProgress(log, nil) + drainProgressUpdateChannel(&progress) + + ch := make(chan Message) + go func() { + for _, message := range messages { + progress.addMessage(message.ID, nil) + progress.messageExported(message.ID, []byte(""), nil) + ch <- message + } + close(ch) + }() + + go func() { + provider.TransferFrom(rules, &progress, ch) + progress.finish() + }() + + maxWait := time.Duration(len(messages)) * time.Second + a.Eventually(t, func() bool { + return progress.updateCh == nil + }, maxWait, 10*time.Millisecond, "Waiting for imported messages timed out") + + r.Empty(t, progress.GetFailedMessages()) +} + +func testTransferFromTo(t *testing.T, rules transferRules, source SourceProvider, target TargetProvider, maxWait time.Duration) { + progress := newProgress(log, nil) + drainProgressUpdateChannel(&progress) + + ch := make(chan Message) + go func() { + source.TransferTo(rules, &progress, ch) + close(ch) + }() + go func() { + target.TransferFrom(rules, &progress, ch) + progress.finish() + }() + + a.Eventually(t, func() bool { + return progress.updateCh == nil + }, maxWait, 10*time.Millisecond, "Waiting for export and import timed out") + + r.Empty(t, progress.GetFailedMessages()) +} diff --git a/internal/transfer/report.go b/internal/transfer/report.go new file mode 100644 index 00000000..a09d9b38 --- /dev/null +++ b/internal/transfer/report.go @@ -0,0 +1,145 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/pkg/errors" +) + +// fileReport is struct which can write and read message details. +// File report includes private information. +type fileReport struct { + path string +} + +func openLastFileReport(reportsPath, importID string) (*fileReport, error) { //nolint[deadcode] + allLogFileNames, err := getFilePathsWithSuffix(reportsPath, ".log") + if err != nil { + return nil, err + } + + reportFileNames := []string{} + for _, fileName := range allLogFileNames { + if strings.HasPrefix(fileName, fmt.Sprintf("import_%s_", importID)) { + reportFileNames = append(reportFileNames, fileName) + } + } + if len(reportFileNames) == 0 { + return nil, errors.New("no report found") + } + + sort.Strings(reportFileNames) + reportFileName := reportFileNames[len(reportFileNames)-1] + path := filepath.Join(reportsPath, reportFileName) + return &fileReport{ + path: path, + }, nil +} + +func newFileReport(reportsPath, importID string) *fileReport { + fileName := fmt.Sprintf("import_%s_%d.log", importID, time.Now().Unix()) + path := filepath.Join(reportsPath, fileName) + + return &fileReport{ + path: path, + } +} + +func (r *fileReport) writeMessageStatus(messageStatus *MessageStatus) { + f, err := os.OpenFile(r.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + log.WithError(err).Error("Failed to open report file") + } + defer f.Close() //nolint[errcheck] + + messageReport := newMessageReportFromMessageStatus(messageStatus, true) + data, err := json.Marshal(messageReport) + if err != nil { + log.WithError(err).Error("Failed to marshall message details") + } + data = append(data, '\n') + + if _, err = f.Write(data); err != nil { + log.WithError(err).Error("Failed to write to report file") + } +} + +// bugReport is struct which can create report for bug reporting. +// Bug report does NOT include private information. +type bugReport struct { + data bytes.Buffer +} + +func (r *bugReport) writeMessageStatus(messageStatus *MessageStatus) { + messageReport := newMessageReportFromMessageStatus(messageStatus, false) + data, err := json.Marshal(messageReport) + if err != nil { + log.WithError(err).Error("Failed to marshall message details") + } + _, _ = r.data.Write(data) + _, _ = r.data.Write([]byte("\n")) +} + +func (r *bugReport) getData() []byte { + return r.data.Bytes() +} + +// messageReport is struct which holds data used by `fileReport` and `bugReport`. +type messageReport struct { + EventTime int64 + SourceID string + TargetID string + BodyHash string + SourceMailbox string + TargetMailboxes []string + Error string + + // Private information for user. + Subject string + From string + Time string +} + +func newMessageReportFromMessageStatus(messageStatus *MessageStatus, includePrivateInfo bool) messageReport { + md := messageReport{ + EventTime: messageStatus.eventTime.Unix(), + SourceID: messageStatus.SourceID, + TargetID: messageStatus.targetID, + BodyHash: messageStatus.bodyHash, + SourceMailbox: messageStatus.rule.SourceMailbox.Name, + TargetMailboxes: messageStatus.rule.TargetMailboxNames(), + Error: messageStatus.GetErrorMessage(), + } + + if includePrivateInfo { + md.Subject = messageStatus.Subject + md.From = messageStatus.From + md.Time = messageStatus.Time.Format(time.RFC1123Z) + } + + return md +} diff --git a/internal/transfer/rules.go b/internal/transfer/rules.go new file mode 100644 index 00000000..2bf03915 --- /dev/null +++ b/internal/transfer/rules.go @@ -0,0 +1,290 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/pkg/errors" +) + +// transferRules maintains import rules, e.g. to which target mailbox should be +// source mailbox imported or what time spans. +type transferRules struct { + filePath string + + // rules is map with key as hash of source mailbox to its rule. + // Every source mailbox should have rule, at least disabled one. + rules map[string]*Rule + + // globalMailbox is applied to every message in the import phase. + // E.g., every message will be imported into this mailbox. + globalMailbox *Mailbox + + // skipEncryptedMessages determines whether message which cannot + // be decrypted should be exported or skipped. + skipEncryptedMessages bool +} + +// loadRules loads rules from `rulesPath` based on `ruleID`. +func loadRules(rulesPath, ruleID string) transferRules { + fileName := fmt.Sprintf("rules_%s.json", ruleID) + filePath := filepath.Join(rulesPath, fileName) + + var rules map[string]*Rule + f, err := os.Open(filePath) //nolint[gosec] + if err != nil { + log.WithError(err).Debug("Problem to read rules") + } else { + defer f.Close() //nolint[errcheck] + if err := json.NewDecoder(f).Decode(&rules); err != nil { + log.WithError(err).Warn("Problem to umarshal rules") + } + } + if rules == nil { + rules = map[string]*Rule{} + } + + return transferRules{ + filePath: filePath, + rules: rules, + } +} + +func (r *transferRules) setSkipEncryptedMessages(skip bool) { + r.skipEncryptedMessages = skip +} + +func (r *transferRules) setGlobalMailbox(mailbox *Mailbox) { + r.globalMailbox = mailbox +} + +func (r *transferRules) setGlobalTimeLimit(fromTime, toTime int64) { + for _, rule := range r.rules { + if !rule.HasTimeLimit() { + rule.FromTime = fromTime + rule.ToTime = toTime + } + } +} + +func (r *transferRules) getRuleBySourceMailboxName(name string) (*Rule, error) { + for _, rule := range r.rules { + if rule.SourceMailbox.Name == name { + return rule, nil + } + } + return nil, fmt.Errorf("no rule for mailbox %s", name) +} + +func (r *transferRules) iterateActiveRules() chan *Rule { + ch := make(chan *Rule) + go func() { + for _, rule := range r.rules { + if rule.Active { + ch <- rule + } + } + close(ch) + }() + return ch +} + +// setDefaultRules iterates `sourceMailboxes` and sets missing rules with +// matching mailboxes from `targetMailboxes`. In case no matching mailbox +// is found, `defaultCallback` with a source mailbox as a parameter is used. +func (r *transferRules) setDefaultRules(sourceMailboxes []Mailbox, targetMailboxes []Mailbox, defaultCallback func(Mailbox) []Mailbox) { + for _, sourceMailbox := range sourceMailboxes { + h := sourceMailbox.Hash() + if _, ok := r.rules[h]; ok { + continue + } + + targetMailboxes := sourceMailbox.findMatchingMailboxes(targetMailboxes) + if len(targetMailboxes) == 0 { + targetMailboxes = defaultCallback(sourceMailbox) + } + + active := true + if len(targetMailboxes) == 0 { + active = false + } + + // For both import to or export from ProtonMail, spam and draft + // mailboxes are by default deactivated. + for _, mailbox := range append([]Mailbox{sourceMailbox}, targetMailboxes...) { + if mailbox.ID == pmapi.SpamLabel || mailbox.ID == pmapi.DraftLabel || mailbox.ID == pmapi.TrashLabel { + active = false + break + } + } + + r.rules[h] = &Rule{ + Active: active, + SourceMailbox: sourceMailbox, + TargetMailboxes: targetMailboxes, + } + } + + for _, rule := range r.rules { + if !rule.Active { + continue + } + found := false + for _, sourceMailbox := range sourceMailboxes { + if sourceMailbox.Name == rule.SourceMailbox.Name { + found = true + } + } + if !found { + rule.Active = false + } + } + + r.save() +} + +// setRule sets messages from `sourceMailbox` between `fromData` and `toDate` +// (if used) to be imported to all `targetMailboxes`. +func (r *transferRules) setRule(sourceMailbox Mailbox, targetMailboxes []Mailbox, fromTime, toTime int64) error { + numberOfExclusiveMailboxes := 0 + for _, mailbox := range targetMailboxes { + if mailbox.IsExclusive { + numberOfExclusiveMailboxes++ + } + } + if numberOfExclusiveMailboxes > 1 { + return errors.New("rule can have only one exclusive target mailbox") + } + + h := sourceMailbox.Hash() + r.rules[h] = &Rule{ + Active: true, + SourceMailbox: sourceMailbox, + TargetMailboxes: targetMailboxes, + FromTime: fromTime, + ToTime: toTime, + } + r.save() + return nil +} + +// unsetRule unsets messages from `sourceMailbox` to be exported. +func (r *transferRules) unsetRule(sourceMailbox Mailbox) { + h := sourceMailbox.Hash() + if rule, ok := r.rules[h]; ok { + rule.Active = false + } else { + r.rules[h] = &Rule{ + Active: false, + SourceMailbox: sourceMailbox, + } + } + r.save() +} + +// getRule returns rule for `sourceMailbox` or nil if it does not exist. +func (r *transferRules) getRule(sourceMailbox Mailbox) *Rule { + h := sourceMailbox.Hash() + return r.rules[h] +} + +// getRules returns all set rules. +func (r *transferRules) getRules() []*Rule { + rules := []*Rule{} + for _, rule := range r.rules { + rules = append(rules, rule) + } + return rules +} + +// reset wipes our all rules. +func (r *transferRules) reset() { + r.rules = map[string]*Rule{} + r.save() +} + +// save saves rules to file. +func (r *transferRules) save() { + f, err := os.Create(r.filePath) + if err != nil { + log.WithError(err).Warn("Problem to write rules") + return + } + defer f.Close() //nolint[errcheck] + + if err := json.NewEncoder(f).Encode(r.rules); err != nil { + log.WithError(err).Warn("Problem to marshal rules") + } +} + +// Rule is data holder of rule for one source mailbox used by `transferRules`. +type Rule struct { + Active bool `json:"active"` + SourceMailbox Mailbox `json:"source"` + TargetMailboxes []Mailbox `json:"targets"` + FromTime int64 `json:"from"` + ToTime int64 `json:"to"` +} + +// String returns textual representation for log purposes. +func (r *Rule) String() string { + return fmt.Sprintf( + "%s -> %s (%d - %d)", + r.SourceMailbox.Name, + strings.Join(r.TargetMailboxNames(), ", "), + r.FromTime, + r.ToTime, + ) +} + +func (r *Rule) isTimeInRange(t int64) bool { + if !r.HasTimeLimit() { + return true + } + return r.FromTime <= t && t <= r.ToTime +} + +// HasTimeLimit returns whether rule defines time limit. +func (r *Rule) HasTimeLimit() bool { + return r.FromTime != 0 || r.ToTime != 0 +} + +// FromDate returns time struct based on `FromTime`. +func (r *Rule) FromDate() time.Time { + return time.Unix(r.FromTime, 0) +} + +// ToDate returns time struct based on `ToTime`. +func (r *Rule) ToDate() time.Time { + return time.Unix(r.ToTime, 0) +} + +// TargetMailboxNames returns array of target mailbox names. +func (r *Rule) TargetMailboxNames() (names []string) { + for _, mailbox := range r.TargetMailboxes { + names = append(names, mailbox.Name) + } + return +} diff --git a/internal/transfer/rules_test.go b/internal/transfer/rules_test.go new file mode 100644 index 00000000..f887a8ac --- /dev/null +++ b/internal/transfer/rules_test.go @@ -0,0 +1,210 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +import ( + "fmt" + "io/ioutil" + "os" + "testing" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + r "github.com/stretchr/testify/require" +) + +func newTestRules(t *testing.T) (transferRules, func()) { + path, err := ioutil.TempDir("", "rules") + r.NoError(t, err) + + ruleID := "rule" + rules := loadRules(path, ruleID) + return rules, func() { + _ = os.RemoveAll(path) + } +} + +func TestLoadRules(t *testing.T) { + path, err := ioutil.TempDir("", "rules") + r.NoError(t, err) + defer os.RemoveAll(path) //nolint[errcheck] + + ruleID := "rule" + rules := loadRules(path, ruleID) + + mailboxA := Mailbox{ID: "1", Name: "One", Color: "orange", IsExclusive: true} + mailboxB := Mailbox{ID: "2", Name: "Two", Color: "", IsExclusive: true} + mailboxC := Mailbox{ID: "3", Name: "Three", Color: "", IsExclusive: false} + + r.NoError(t, rules.setRule(mailboxA, []Mailbox{mailboxB, mailboxC}, 0, 0)) + r.NoError(t, rules.setRule(mailboxB, []Mailbox{mailboxB}, 10, 20)) + r.NoError(t, rules.setRule(mailboxC, []Mailbox{}, 0, 30)) + + rules2 := loadRules(path, ruleID) + r.Equal(t, map[string]*Rule{ + mailboxA.Hash(): {Active: true, SourceMailbox: mailboxA, TargetMailboxes: []Mailbox{mailboxB, mailboxC}, FromTime: 0, ToTime: 0}, + mailboxB.Hash(): {Active: true, SourceMailbox: mailboxB, TargetMailboxes: []Mailbox{mailboxB}, FromTime: 10, ToTime: 20}, + mailboxC.Hash(): {Active: true, SourceMailbox: mailboxC, TargetMailboxes: []Mailbox{}, FromTime: 0, ToTime: 30}, + }, rules2.rules) + + rules2.unsetRule(mailboxA) + rules2.unsetRule(mailboxC) + + rules3 := loadRules(path, ruleID) + r.Equal(t, map[string]*Rule{ + mailboxA.Hash(): {Active: false, SourceMailbox: mailboxA, TargetMailboxes: []Mailbox{mailboxB, mailboxC}, FromTime: 0, ToTime: 0}, + mailboxB.Hash(): {Active: true, SourceMailbox: mailboxB, TargetMailboxes: []Mailbox{mailboxB}, FromTime: 10, ToTime: 20}, + mailboxC.Hash(): {Active: false, SourceMailbox: mailboxC, TargetMailboxes: []Mailbox{}, FromTime: 0, ToTime: 30}, + }, rules3.rules) +} + +func TestSetGlobalTimeLimit(t *testing.T) { + path, err := ioutil.TempDir("", "rules") + r.NoError(t, err) + defer os.RemoveAll(path) //nolint[errcheck] + + rules := loadRules(path, "rule") + + mailboxA := Mailbox{Name: "One"} + mailboxB := Mailbox{Name: "Two"} + + r.NoError(t, rules.setRule(mailboxA, []Mailbox{}, 10, 20)) + r.NoError(t, rules.setRule(mailboxB, []Mailbox{}, 0, 0)) + + rules.setGlobalTimeLimit(30, 40) + + r.Equal(t, map[string]*Rule{ + mailboxA.Hash(): {Active: true, SourceMailbox: mailboxA, TargetMailboxes: []Mailbox{}, FromTime: 10, ToTime: 20}, + mailboxB.Hash(): {Active: true, SourceMailbox: mailboxB, TargetMailboxes: []Mailbox{}, FromTime: 30, ToTime: 40}, + }, rules.rules) +} + +func TestSetDefaultRules(t *testing.T) { + path, err := ioutil.TempDir("", "rules") + r.NoError(t, err) + defer os.RemoveAll(path) //nolint[errcheck] + + rules := loadRules(path, "rule") + + mailbox1 := Mailbox{Name: "One"} // Set manually, default will not override it. + mailbox2 := Mailbox{Name: "Two"} // Matched by `targetMailboxes`. + mailbox3 := Mailbox{Name: "Three"} // Matched by `defaultCallback`, not included in `targetMailboxes`. + mailbox4 := Mailbox{Name: "Four"} // Matched by nothing, will not be active. + mailbox5 := Mailbox{Name: "Spam", ID: pmapi.SpamLabel} // Spam is inactive by default (ID found in source). + mailbox6a := Mailbox{Name: "Draft"} // Draft is inactive by default (ID found in target, mailbox6b). + mailbox6b := Mailbox{Name: "Draft", ID: pmapi.DraftLabel} + + sourceMailboxes := []Mailbox{mailbox1, mailbox2, mailbox3, mailbox4, mailbox5, mailbox6a} + targetMailboxes := []Mailbox{mailbox1, mailbox2, mailbox6b} + + r.NoError(t, rules.setRule(mailbox1, []Mailbox{mailbox3}, 0, 0)) + + defaultCallback := func(mailbox Mailbox) []Mailbox { + if mailbox.Name == "Three" { + return []Mailbox{mailbox3} + } + return []Mailbox{} + } + + rules.setDefaultRules(sourceMailboxes, targetMailboxes, defaultCallback) + + r.Equal(t, map[string]*Rule{ + mailbox1.Hash(): {Active: true, SourceMailbox: mailbox1, TargetMailboxes: []Mailbox{mailbox3}}, + mailbox2.Hash(): {Active: true, SourceMailbox: mailbox2, TargetMailboxes: []Mailbox{mailbox2}}, + mailbox3.Hash(): {Active: true, SourceMailbox: mailbox3, TargetMailboxes: []Mailbox{mailbox3}}, + mailbox4.Hash(): {Active: false, SourceMailbox: mailbox4, TargetMailboxes: []Mailbox{}}, + mailbox5.Hash(): {Active: false, SourceMailbox: mailbox5, TargetMailboxes: []Mailbox{}}, + mailbox6a.Hash(): {Active: false, SourceMailbox: mailbox6a, TargetMailboxes: []Mailbox{mailbox6b}}, + }, rules.rules) +} + +func TestSetDefaultRulesDeactivateMissing(t *testing.T) { + path, err := ioutil.TempDir("", "rules") + r.NoError(t, err) + defer os.RemoveAll(path) //nolint[errcheck] + + rules := loadRules(path, "rule") + + mailboxA := Mailbox{ID: "1", Name: "One", Color: "", IsExclusive: true} + mailboxB := Mailbox{ID: "2", Name: "Two", Color: "", IsExclusive: true} + + r.NoError(t, rules.setRule(mailboxA, []Mailbox{mailboxB}, 0, 0)) + r.NoError(t, rules.setRule(mailboxB, []Mailbox{mailboxB}, 0, 0)) + + sourceMailboxes := []Mailbox{mailboxA} + targetMailboxes := []Mailbox{mailboxA, mailboxB} + defaultCallback := func(mailbox Mailbox) (mailboxes []Mailbox) { + return + } + rules.setDefaultRules(sourceMailboxes, targetMailboxes, defaultCallback) + + r.Equal(t, map[string]*Rule{ + mailboxA.Hash(): {Active: true, SourceMailbox: mailboxA, TargetMailboxes: []Mailbox{mailboxB}, FromTime: 0, ToTime: 0}, + mailboxB.Hash(): {Active: false, SourceMailbox: mailboxB, TargetMailboxes: []Mailbox{mailboxB}, FromTime: 0, ToTime: 0}, + }, rules.rules) +} + +func TestIsTimeInRange(t *testing.T) { + tests := []struct { + rule Rule + time int64 + want bool + }{ + {generateTimeRule(0, 0), 0, true}, + {generateTimeRule(0, 0), 10, true}, + {generateTimeRule(0, 15), 10, true}, + {generateTimeRule(5, 15), 10, true}, + {generateTimeRule(0, 5), 10, false}, + {generateTimeRule(5, 7), 10, false}, + {generateTimeRule(15, 30), 10, false}, + {generateTimeRule(15, 0), 10, false}, + } + for _, tc := range tests { + tc := tc + t.Run(fmt.Sprintf("%v / %d", tc.rule, tc.time), func(t *testing.T) { + got := tc.rule.isTimeInRange(tc.time) + r.Equal(t, tc.want, got) + }) + } +} + +func TestHasTimeLimit(t *testing.T) { + tests := []struct { + rule Rule + want bool + }{ + {generateTimeRule(0, 0), false}, + {generateTimeRule(0, 1), true}, + {generateTimeRule(1, 2), true}, + {generateTimeRule(1, 0), true}, + } + for _, tc := range tests { + tc := tc + t.Run(fmt.Sprintf("%v", tc.rule), func(t *testing.T) { + r.Equal(t, tc.want, tc.rule.HasTimeLimit()) + }) + } +} + +func generateTimeRule(from, to int64) Rule { + return Rule{ + SourceMailbox: Mailbox{}, + TargetMailboxes: []Mailbox{}, + FromTime: from, + ToTime: to, + } +} diff --git a/internal/transfer/testdata/eml/Foo/msg.eml b/internal/transfer/testdata/eml/Foo/msg.eml new file mode 100644 index 00000000..3b342260 --- /dev/null +++ b/internal/transfer/testdata/eml/Foo/msg.eml @@ -0,0 +1,4 @@ +From: Bridge Test +To: Bridge Test + +hello diff --git a/internal/transfer/testdata/eml/Inbox/msg.eml b/internal/transfer/testdata/eml/Inbox/msg.eml new file mode 100644 index 00000000..3b342260 --- /dev/null +++ b/internal/transfer/testdata/eml/Inbox/msg.eml @@ -0,0 +1,4 @@ +From: Bridge Test +To: Bridge Test + +hello diff --git a/internal/transfer/testdata/emlmbox/Foo/msg.eml b/internal/transfer/testdata/emlmbox/Foo/msg.eml new file mode 100644 index 00000000..3b342260 --- /dev/null +++ b/internal/transfer/testdata/emlmbox/Foo/msg.eml @@ -0,0 +1,4 @@ +From: Bridge Test +To: Bridge Test + +hello diff --git a/internal/transfer/testdata/emlmbox/Inbox.mbox b/internal/transfer/testdata/emlmbox/Inbox.mbox new file mode 100644 index 00000000..25ad1c5b --- /dev/null +++ b/internal/transfer/testdata/emlmbox/Inbox.mbox @@ -0,0 +1,5 @@ +From - Mon May 4 16:40:31 2020 +From: Bridge Test +To: Bridge Test + +hello diff --git a/internal/transfer/testdata/keyring_userKey b/internal/transfer/testdata/keyring_userKey new file mode 100644 index 00000000..976d2be2 --- /dev/null +++ b/internal/transfer/testdata/keyring_userKey @@ -0,0 +1,62 @@ +-----BEGIN PGP PRIVATE KEY BLOCK----- +Version: OpenPGP.js v4.4.5 +Comment: testpassphrase + +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 +uaSC3IcBmBsj1fNb4eYXElILjQ== +=fMOl +-----END PGP PRIVATE KEY BLOCK----- \ No newline at end of file diff --git a/internal/transfer/testdata/mbox/Foo.mbox b/internal/transfer/testdata/mbox/Foo.mbox new file mode 100644 index 00000000..25ad1c5b --- /dev/null +++ b/internal/transfer/testdata/mbox/Foo.mbox @@ -0,0 +1,5 @@ +From - Mon May 4 16:40:31 2020 +From: Bridge Test +To: Bridge Test + +hello diff --git a/internal/transfer/testdata/mbox/Inbox.mbox b/internal/transfer/testdata/mbox/Inbox.mbox new file mode 100644 index 00000000..25ad1c5b --- /dev/null +++ b/internal/transfer/testdata/mbox/Inbox.mbox @@ -0,0 +1,5 @@ +From - Mon May 4 16:40:31 2020 +From: Bridge Test +To: Bridge Test + +hello diff --git a/internal/transfer/transfer.go b/internal/transfer/transfer.go new file mode 100644 index 00000000..bcd7290d --- /dev/null +++ b/internal/transfer/transfer.go @@ -0,0 +1,177 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Package transfer provides tools to export messages from one provider and +// import them to another provider. Provider can be EML, MBOX, IMAP or PMAPI. +package transfer + +import ( + "crypto/sha256" + "fmt" + + "github.com/sirupsen/logrus" +) + +var log = logrus.WithField("pkg", "transfer") //nolint[gochecknoglobals] + +// Transfer is facade on top of import rules, progress manager and source +// and target providers. This is the main object which should be used. +type Transfer struct { + panicHandler PanicHandler + id string + dir string + rules transferRules + source SourceProvider + target TargetProvider +} + +// New creates Transfer for specific source and target. Usage: +// +// source := transfer.NewEMLProvider(...) +// target := transfer.NewPMAPIProvider(...) +// transfer.New(source, target, ...) +func New(panicHandler PanicHandler, transferDir string, source SourceProvider, target TargetProvider) (*Transfer, error) { + transferID := fmt.Sprintf("%x", sha256.Sum256([]byte(source.ID()+"-"+target.ID()))) + rules := loadRules(transferDir, transferID) + transfer := &Transfer{ + panicHandler: panicHandler, + id: transferID, + dir: transferDir, + rules: rules, + source: source, + target: target, + } + if err := transfer.setDefaultRules(); err != nil { + return nil, err + } + return transfer, nil +} + +// SetDefaultRules sets missing rules for source mailboxes with matching +// target mailboxes. In case no matching mailbox is found, `defaultCallback` +// with a source mailbox as a parameter is used. +func (t *Transfer) setDefaultRules() error { + sourceMailboxes, err := t.SourceMailboxes() + if err != nil { + return err + } + + targetMailboxes, err := t.TargetMailboxes() + if err != nil { + return err + } + + defaultCallback := func(sourceMailbox Mailbox) []Mailbox { + return t.target.DefaultMailboxes(sourceMailbox) + } + + t.rules.setDefaultRules(sourceMailboxes, targetMailboxes, defaultCallback) + return nil +} + +// SetSkipEncryptedMessages sets whether message which cannot be decrypted +// should be exported or skipped. +func (t *Transfer) SetSkipEncryptedMessages(skip bool) { + t.rules.setSkipEncryptedMessages(skip) +} + +// SetGlobalMailbox sets mailbox that is applied to every message in +// the import phase. +func (t *Transfer) SetGlobalMailbox(mailbox *Mailbox) { + t.rules.setGlobalMailbox(mailbox) +} + +// SetGlobalTimeLimit sets time limit that is applied to rules without any +// specified time limit. +func (t *Transfer) SetGlobalTimeLimit(fromTime, toTime int64) { + t.rules.setGlobalTimeLimit(fromTime, toTime) +} + +// SetRule sets sourceMailbox for transfer. +func (t *Transfer) SetRule(sourceMailbox Mailbox, targetMailboxes []Mailbox, fromTime, toTime int64) error { + return t.rules.setRule(sourceMailbox, targetMailboxes, fromTime, toTime) +} + +// UnsetRule unsets sourceMailbox from transfer. +func (t *Transfer) UnsetRule(sourceMailbox Mailbox) { + t.rules.unsetRule(sourceMailbox) +} + +// ResetRules unsets all rules. +func (t *Transfer) ResetRules() { + t.rules.reset() +} + +// GetRule returns rule for given mailbox. +func (t *Transfer) GetRule(sourceMailbox Mailbox) *Rule { + return t.rules.getRule(sourceMailbox) +} + +// GetRules returns all set transfer rules. +func (t *Transfer) GetRules() []*Rule { + return t.rules.getRules() +} + +// SourceMailboxes returns mailboxes available at source side. +func (t *Transfer) SourceMailboxes() ([]Mailbox, error) { + return t.source.Mailboxes(false, true) +} + +// TargetMailboxes returns mailboxes available at target side. +func (t *Transfer) TargetMailboxes() ([]Mailbox, error) { + return t.target.Mailboxes(true, false) +} + +// CreateTargetMailbox creates mailbox in target provider. +func (t *Transfer) CreateTargetMailbox(mailbox Mailbox) (Mailbox, error) { + return t.target.CreateMailbox(mailbox) +} + +// ChangeTarget allows to change target. Ideally should not be used. +// Useful for situration after user changes mind where to export files and similar. +func (t *Transfer) ChangeTarget(target TargetProvider) { + t.target = target +} + +// Start starts the transfer from source to target. +func (t *Transfer) Start() *Progress { + log.Debug("Transfer started") + t.rules.save() + + log := log.WithField("id", t.id) + reportFile := newFileReport(t.dir, t.id) + progress := newProgress(log, reportFile) + + ch := make(chan Message) + + go func() { + defer t.panicHandler.HandlePanic() + + progress.start() + t.source.TransferTo(t.rules, &progress, ch) + close(ch) + }() + + go func() { + defer t.panicHandler.HandlePanic() + + t.target.TransferFrom(t.rules, &progress, ch) + progress.finish() + }() + + return &progress +} diff --git a/internal/transfer/transfer_test.go b/internal/transfer/transfer_test.go new file mode 100644 index 00000000..9a7c0bf7 --- /dev/null +++ b/internal/transfer/transfer_test.go @@ -0,0 +1,73 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +import ( + "bytes" + "io/ioutil" + "testing" + + "github.com/ProtonMail/gopenpgp/crypto" + transfermocks "github.com/ProtonMail/proton-bridge/internal/transfer/mocks" + pmapimocks "github.com/ProtonMail/proton-bridge/pkg/pmapi/mocks" + gomock "github.com/golang/mock/gomock" +) + +type mocks struct { + t *testing.T + + ctrl *gomock.Controller + panicHandler *transfermocks.MockPanicHandler + clientManager *transfermocks.MockClientManager + pmapiClient *pmapimocks.MockClient + + keyring *crypto.KeyRing +} + +func initMocks(t *testing.T) mocks { + mockCtrl := gomock.NewController(t) + + m := mocks{ + t: t, + + ctrl: mockCtrl, + panicHandler: transfermocks.NewMockPanicHandler(mockCtrl), + clientManager: transfermocks.NewMockClientManager(mockCtrl), + pmapiClient: pmapimocks.NewMockClient(mockCtrl), + keyring: newTestKeyring(), + } + + m.clientManager.EXPECT().GetClient("user").Return(m.pmapiClient).AnyTimes() + + return m +} + +func newTestKeyring() *crypto.KeyRing { + data, err := ioutil.ReadFile("testdata/keyring_userKey") + if err != nil { + panic(err) + } + userKey, err := crypto.ReadArmoredKeyRing(bytes.NewReader(data)) + if err != nil { + panic(err) + } + if err := userKey.Unlock([]byte("testpassphrase")); err != nil { + panic(err) + } + return userKey +} diff --git a/internal/transfer/types.go b/internal/transfer/types.go new file mode 100644 index 00000000..9b12db49 --- /dev/null +++ b/internal/transfer/types.go @@ -0,0 +1,31 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +import ( + "github.com/ProtonMail/proton-bridge/pkg/pmapi" +) + +type PanicHandler interface { + HandlePanic() +} + +type ClientManager interface { + GetClient(userID string) pmapi.Client + CheckConnection() error +} diff --git a/internal/transfer/utils.go b/internal/transfer/utils.go new file mode 100644 index 00000000..17bd62c7 --- /dev/null +++ b/internal/transfer/utils.go @@ -0,0 +1,141 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +import ( + "bufio" + "bytes" + "io/ioutil" + "net/mail" + "net/textproto" + "path/filepath" + "sort" + "strings" + + "github.com/pkg/errors" +) + +// getFolderNames collects all folder names under `root`. +// Folder names will be without a path. +func getFolderNames(root string) ([]string, error) { + return getFolderNamesWithFileSuffix(root, "") +} + +// getFolderNamesWithFileSuffix collects all folder names under `root`, which +// contains some file with a give `fileSuffix`. Names will be without a path. +func getFolderNamesWithFileSuffix(root, fileSuffix string) ([]string, error) { + folders := []string{} + + files, err := ioutil.ReadDir(root) + if err != nil { + return nil, err + } + + hasFileWithSuffix := fileSuffix == "" + for _, file := range files { + if file.IsDir() { + subfolders, err := getFolderNamesWithFileSuffix(filepath.Join(root, file.Name()), fileSuffix) + if err != nil { + return nil, err + } + for _, subfolder := range subfolders { + match := false + for _, folder := range folders { + if folder == subfolder { + match = true + break + } + } + if !match { + folders = append(folders, subfolder) + } + } + } else if fileSuffix == "" || strings.HasSuffix(file.Name(), fileSuffix) { + hasFileWithSuffix = true + } + } + + if hasFileWithSuffix { + folders = append(folders, filepath.Base(root)) + } + + sort.Strings(folders) + return folders, nil +} + +// getFilePathsWithSuffix collects all file names with `suffix` under `root`. +// File names will be with relative path based to `root`. +func getFilePathsWithSuffix(root, suffix string) ([]string, error) { + fileNames, err := getFilePathsWithSuffixInner("", root, suffix) + if err != nil { + return nil, err + } + sort.Strings(fileNames) + return fileNames, err +} + +func getFilePathsWithSuffixInner(prefix, root, suffix string) ([]string, error) { + fileNames := []string{} + + files, err := ioutil.ReadDir(root) + if err != nil { + return nil, err + } + + for _, file := range files { + if !file.IsDir() { + if strings.HasSuffix(file.Name(), suffix) { + fileNames = append(fileNames, filepath.Join(prefix, file.Name())) + } + } else { + subfolderFileNames, err := getFilePathsWithSuffixInner( + filepath.Join(prefix, file.Name()), + filepath.Join(root, file.Name()), + suffix, + ) + if err != nil { + return nil, err + } + fileNames = append(fileNames, subfolderFileNames...) + } + } + + return fileNames, nil +} + +// getMessageTime returns time of the message specified in the message header. +func getMessageTime(body []byte) (int64, error) { + mailHeader, err := getMessageHeader(body) + if err != nil { + return 0, err + } + if t, err := mailHeader.Date(); err == nil && !t.IsZero() { + return t.Unix(), nil + } + return 0, nil +} + +// getMessageHeader returns headers of the message body. +func getMessageHeader(body []byte) (mail.Header, error) { + tpr := textproto.NewReader(bufio.NewReader(bytes.NewBuffer(body))) + header, err := tpr.ReadMIMEHeader() + if err != nil { + return nil, errors.Wrap(err, "failed to read headers") + } + return mail.Header(header), nil +} diff --git a/internal/transfer/utils_test.go b/internal/transfer/utils_test.go new file mode 100644 index 00000000..fc2d83e0 --- /dev/null +++ b/internal/transfer/utils_test.go @@ -0,0 +1,190 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + r "github.com/stretchr/testify/require" +) + +func TestGetFolderNames(t *testing.T) { + root, clean := createTestingFolderStructure(t) + defer clean() + + tests := []struct { + suffix string + wantNames []string + }{ + { + "", + []string{ + "bar", + "baz", + filepath.Base(root), + "foo", + "qwerty", + "test", + }, + }, + { + ".eml", + []string{ + "bar", + "baz", + filepath.Base(root), + "foo", + }, + }, + { + ".txt", + []string{ + filepath.Base(root), + }, + }, + } + for _, tc := range tests { + tc := tc + t.Run(tc.suffix, func(t *testing.T) { + names, err := getFolderNamesWithFileSuffix(root, tc.suffix) + r.NoError(t, err) + r.Equal(t, tc.wantNames, names) + }) + } +} + +func TestGetFilePathsWithSuffix(t *testing.T) { + root, clean := createTestingFolderStructure(t) + defer clean() + + tests := []struct { + suffix string + wantPaths []string + }{ + { + ".eml", + []string{ + "foo/bar/baz/msg1.eml", + "foo/bar/baz/msg2.eml", + "foo/bar/baz/msg3.eml", + "foo/bar/msg4.eml", + "foo/bar/msg5.eml", + "foo/baz/msg6.eml", + "foo/msg7.eml", + "msg10.eml", + "test/foo/msg8.eml", + "test/foo/msg9.eml", + }, + }, + { + ".txt", + []string{ + "info.txt", + }, + }, + { + ".hello", + []string{}, + }, + } + for _, tc := range tests { + tc := tc + t.Run(tc.suffix, func(t *testing.T) { + paths, err := getFilePathsWithSuffix(root, tc.suffix) + r.NoError(t, err) + r.Equal(t, tc.wantPaths, paths) + }) + } +} + +func createTestingFolderStructure(t *testing.T) (string, func()) { + root, err := ioutil.TempDir("", "folderstructure") + r.NoError(t, err) + + for _, path := range []string{ + "foo/bar/baz", + "foo/baz", + "test/foo", + "qwerty", + } { + err = os.MkdirAll(filepath.Join(root, path), os.ModePerm) + r.NoError(t, err) + } + + for _, path := range []string{ + "foo/bar/baz/msg1.eml", + "foo/bar/baz/msg2.eml", + "foo/bar/baz/msg3.eml", + "foo/bar/msg4.eml", + "foo/bar/msg5.eml", + "foo/baz/msg6.eml", + "foo/msg7.eml", + "test/foo/msg8.eml", + "test/foo/msg9.eml", + "msg10.eml", + "info.txt", + } { + f, err := os.Create(filepath.Join(root, path)) + r.NoError(t, err) + err = f.Close() + r.NoError(t, err) + } + + return root, func() { + _ = os.RemoveAll(root) + } +} + +func TestGetMessageTime(t *testing.T) { + tests := []struct { + body string + wantTime int64 + wantErr string + }{ + {"", 0, "failed to read headers: EOF"}, + {"Subject: hello\n\n", 0, ""}, + {"Date: Thu, 23 Apr 2020 04:52:44 +0000\n\n", 1587617564, ""}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.body, func(t *testing.T) { + time, err := getMessageTime([]byte(tc.body)) + if tc.wantErr == "" { + r.NoError(t, err) + } else { + r.EqualError(t, err, tc.wantErr) + } + r.Equal(t, tc.wantTime, time) + }) + } +} + +func TestGetMessageHeader(t *testing.T) { + body := `Subject: Hello +From: user@example.com + +Body +` + header, err := getMessageHeader([]byte(body)) + r.NoError(t, err) + r.Equal(t, header.Get("subject"), "Hello") + r.Equal(t, header.Get("from"), "user@example.com") +} diff --git a/internal/users/user.go b/internal/users/user.go index 35e45982..159988b5 100644 --- a/internal/users/user.go +++ b/internal/users/user.go @@ -359,14 +359,17 @@ func (u *User) GetAddressID(address string) (id string, err error) { u.lock.RLock() defer u.lock.RUnlock() - address = strings.ToLower(address) - - if u.store == nil { - err = errors.New("store is not initialised") - return + if u.store != nil { + address = strings.ToLower(address) + return u.store.GetAddressID(address) } - return u.store.GetAddressID(address) + addresses := u.client().Addresses() + pmapiAddress := addresses.ByEmail(address) + if pmapiAddress != nil { + return pmapiAddress.ID, nil + } + return "", errors.New("address not found") } // GetBridgePassword returns bridge password. This is not a password of the PM diff --git a/internal/users/users.go b/internal/users/users.go index f92c4cfb..95ad0402 100644 --- a/internal/users/users.go +++ b/internal/users/users.go @@ -52,8 +52,14 @@ type Users struct { // People are used to that and so we preserve that ordering here. users []*User - // idleUpdates is a channel which the imap backend listens to and which it uses - // to send idle updates to the mail client (eg thunderbird). + // useOnlyActiveAddresses determines whether credentials keeps only active + // addresses or all of them. Each usage has to be consisteng, e.g., once + // user is added, it saves address list to credentials and next time loads + // as is, without requesting server again. + useOnlyActiveAddresses bool + + // idleUpdates is a channel which the imap backend listens to and which it + // uses to send idle updates to the mail client (eg thunderbird). // The user stores should send idle updates on this channel. idleUpdates chan imapBackend.Update @@ -70,19 +76,21 @@ func New( clientManager ClientManager, credStorer CredentialsStorer, storeFactory StoreMaker, + useOnlyActiveAddresses bool, ) *Users { log.Trace("Creating new users") u := &Users{ - config: config, - panicHandler: panicHandler, - events: eventListener, - clientManager: clientManager, - credStorer: credStorer, - storeFactory: storeFactory, - idleUpdates: make(chan imapBackend.Update), - lock: sync.RWMutex{}, - stopAll: make(chan struct{}), + config: config, + panicHandler: panicHandler, + events: eventListener, + clientManager: clientManager, + credStorer: credStorer, + storeFactory: storeFactory, + useOnlyActiveAddresses: useOnlyActiveAddresses, + idleUpdates: make(chan imapBackend.Update), + lock: sync.RWMutex{}, + stopAll: make(chan struct{}), } go func() { @@ -291,9 +299,15 @@ func (u *Users) addNewUser(apiUser *pmapi.User, auth *pmapi.Auth, hashedPassphra return errors.Wrap(err, "failed to update API user") } - activeEmails := client.Addresses().ActiveEmails() + emails := []string{} + for _, address := range client.Addresses() { + if u.useOnlyActiveAddresses && address.Receive != pmapi.CanReceive { + continue + } + emails = append(emails, address.Email) + } - if _, err = u.credStorer.Add(apiUser.ID, apiUser.Name, auth.GenToken(), hashedPassphrase, activeEmails); err != nil { + if _, err = u.credStorer.Add(apiUser.ID, apiUser.Name, auth.GenToken(), hashedPassphrase, emails); err != nil { return errors.Wrap(err, "failed to add user to credentials store") } diff --git a/internal/users/users_test.go b/internal/users/users_test.go index 997a700f..f79fe162 100644 --- a/internal/users/users_test.go +++ b/internal/users/users_test.go @@ -238,7 +238,7 @@ func testNewUsers(t *testing.T, m mocks) *Users { //nolint[unparam] m.eventListener.EXPECT().Add(events.UpgradeApplicationEvent, gomock.Any()) m.clientManager.EXPECT().GetAuthUpdateChannel().Return(make(chan pmapi.ClientAuth)) - users := New(m.config, m.PanicHandler, m.eventListener, m.clientManager, m.credentialsStore, m.storeMaker) + users := New(m.config, m.PanicHandler, m.eventListener, m.clientManager, m.credentialsStore, m.storeMaker, true) waitForEvents() diff --git a/pkg/config/config.go b/pkg/config/config.go index d0432564..f60f0969 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -228,6 +228,11 @@ func (c *Config) GetPreferencesPath() string { return filepath.Join(c.appDirsVersion.UserCache(), "prefs.json") } +// GetTransferDir returns folder for import/export rule and report files. +func (c *Config) GetTransferDir() string { + return filepath.Join(c.appDirsVersion.UserCache()) +} + // GetDefaultAPIPort returns default Bridge local API port. func (c *Config) GetDefaultAPIPort() int { return 1042 diff --git a/pkg/message/body.go b/pkg/message/body.go index 283fc7f3..a704d1e0 100644 --- a/pkg/message/body.go +++ b/pkg/message/body.go @@ -55,7 +55,7 @@ func WriteAttachmentBody(w io.Writer, kr *crypto.KeyRing, m *pmapi.Message, att dr = r err = nil att.Name += ".gpg" - att.MIMEType = "application/pgp-encrypted" + att.MIMEType = "application/pgp-encrypted" //nolint } else if err != nil && err != openpgperrors.ErrSignatureExpired { err = fmt.Errorf("cannot decrypt attachment: %v", err) return diff --git a/pkg/message/build.go b/pkg/message/build.go new file mode 100644 index 00000000..f25cd09b --- /dev/null +++ b/pkg/message/build.go @@ -0,0 +1,348 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package message + +import ( + "bytes" + "encoding/base64" + "fmt" + "io" + "io/ioutil" + "mime/multipart" + "mime/quotedprintable" + "net/textproto" + + "github.com/ProtonMail/gopenpgp/crypto" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/emersion/go-textwrapper" + openpgperrors "golang.org/x/crypto/openpgp/errors" +) + +// Builder for converting PM message to RFC822. Builder will directly write +// changes to message when fetching or building message. +type Builder struct { + cl pmapi.Client + msg *pmapi.Message + + EncryptedToHTML bool + succDcrpt bool +} + +// NewBuilder initiated with client and message meta info. +func NewBuilder(client pmapi.Client, message *pmapi.Message) *Builder { + return &Builder{cl: client, msg: message, EncryptedToHTML: true, succDcrpt: false} +} + +// fetchMessage will update original PM message if successful +func (bld *Builder) fetchMessage() (err error) { + if bld.msg.Body != "" { + return nil + } + + complete, err := bld.cl.GetMessage(bld.msg.ID) + if err != nil { + return + } + + *bld.msg = *complete + + return +} + +func (bld *Builder) writeMessageBody(w io.Writer) error { + if err := bld.fetchMessage(); err != nil { + return err + } + + err := bld.WriteBody(w) + if err != nil { + _, _ = io.WriteString(w, "\r\n") + if bld.EncryptedToHTML { + _ = CustomMessage(bld.msg, err, true) + } + _, err = io.WriteString(w, bld.msg.Body) + _, _ = io.WriteString(w, "\r\n") + } + + return err +} + +func (bld *Builder) writeAttachmentBody(w io.Writer, att *pmapi.Attachment) error { + // Retrieve encrypted attachment + r, err := bld.cl.GetAttachment(att.ID) + if err != nil { + return err + } + defer r.Close() //nolint[errcheck] + + if err := bld.WriteAttachmentBody(w, att, r); err != nil { + // Returning an error here makes e-mail clients like Thunderbird behave + // badly, trying to retrieve the message again and again + log.Warnln("Cannot write attachment body:", err) + } + return nil +} + +func (bld *Builder) writeRelatedPart(p io.Writer, inlines []*pmapi.Attachment) error { + related := multipart.NewWriter(p) + + _ = related.SetBoundary(GetRelatedBoundary(bld.msg)) + + buf := &bytes.Buffer{} + if err := bld.writeMessageBody(buf); err != nil { + return err + } + + // Write the body part + h := GetBodyHeader(bld.msg) + + var err error + if p, err = related.CreatePart(h); err != nil { + return err + } + + _, _ = buf.WriteTo(p) + + for _, inline := range inlines { + buf = &bytes.Buffer{} + if err = bld.writeAttachmentBody(buf, inline); err != nil { + return err + } + + h := GetAttachmentHeader(inline) + if p, err = related.CreatePart(h); err != nil { + return err + } + _, _ = buf.WriteTo(p) + } + + _ = related.Close() + return nil +} + +// BuildMessage converts PM message to body structure (not RFC3501) and bytes +// of RC822 message. If successful the original PM message will contain decrypted body. +func (bld *Builder) BuildMessage() (structure *BodyStructure, message []byte, err error) { //nolint[funlen] + if err = bld.fetchMessage(); err != nil { + return nil, nil, err + } + + bodyBuf := &bytes.Buffer{} + + mainHeader := GetHeader(bld.msg) + mainHeader.Set("Content-Type", "multipart/mixed; boundary="+GetBoundary(bld.msg)) + if err = WriteHeader(bodyBuf, mainHeader); err != nil { + return nil, nil, err + } + _, _ = io.WriteString(bodyBuf, "\r\n") + + // NOTE: Do we really need extra encapsulation? i.e. Bridge-IMAP message is always multipart/mixed + + if bld.msg.MIMEType == pmapi.ContentTypeMultipartMixed { + _, _ = io.WriteString(bodyBuf, "\r\n--"+GetBoundary(bld.msg)+"\r\n") + if err = bld.writeMessageBody(bodyBuf); err != nil { + return nil, nil, err + } + _, _ = io.WriteString(bodyBuf, "\r\n--"+GetBoundary(bld.msg)+"--\r\n") + } else { + mw := multipart.NewWriter(bodyBuf) + _ = mw.SetBoundary(GetBoundary(bld.msg)) + + var partWriter io.Writer + atts, inlines := SeparateInlineAttachments(bld.msg) + + if len(inlines) > 0 { + relatedHeader := GetRelatedHeader(bld.msg) + if partWriter, err = mw.CreatePart(relatedHeader); err != nil { + return nil, nil, err + } + _ = bld.writeRelatedPart(partWriter, inlines) + } else { + buf := &bytes.Buffer{} + if err = bld.writeMessageBody(buf); err != nil { + return nil, nil, err + } + + // Write the body part + bodyHeader := GetBodyHeader(bld.msg) + if partWriter, err = mw.CreatePart(bodyHeader); err != nil { + return nil, nil, err + } + + _, _ = buf.WriteTo(partWriter) + } + + // Write the attachments parts + for _, att := range atts { + buf := &bytes.Buffer{} + if err = bld.writeAttachmentBody(buf, att); err != nil { + return nil, nil, err + } + + attachmentHeader := GetAttachmentHeader(att) + if partWriter, err = mw.CreatePart(attachmentHeader); err != nil { + return nil, nil, err + } + + _, _ = buf.WriteTo(partWriter) + } + + _ = mw.Close() + } + + // wee need to copy buffer before building body structure + message = bodyBuf.Bytes() + structure, err = NewBodyStructure(bodyBuf) + return structure, message, err +} + +// SuccessfullyDecrypted is true when message was fetched and decrypted successfully +func (bld *Builder) SuccessfullyDecrypted() bool { return bld.succDcrpt } + +// WriteBody decrypts PM message and writes main body section. The external PGP +// message is written as is (including attachments) +func (bld *Builder) WriteBody(w io.Writer) error { + kr, err := bld.cl.KeyRingForAddressID(bld.msg.AddressID) + if err != nil { + return err + } + // decrypt body + if err := bld.msg.Decrypt(kr); err != nil && err != openpgperrors.ErrSignatureExpired { + return err + } + bld.succDcrpt = true + if bld.msg.MIMEType != pmapi.ContentTypeMultipartMixed { + // transfer encoding + qp := quotedprintable.NewWriter(w) + if _, err := io.WriteString(qp, bld.msg.Body); err != nil { + return err + } + return qp.Close() + } + _, err = io.WriteString(w, bld.msg.Body) + return err +} + +// WriteAttachmentBody decrypts and writes the attachments +func (bld *Builder) WriteAttachmentBody(w io.Writer, att *pmapi.Attachment, attReader io.Reader) (err error) { + kr, err := bld.cl.KeyRingForAddressID(bld.msg.AddressID) + if err != nil { + return err + } + // Decrypt it + var dr io.Reader + dr, err = att.Decrypt(attReader, kr) + if err == openpgperrors.ErrKeyIncorrect { + // Do not fail if attachment is encrypted with a different key + dr = attReader + err = nil + att.Name += ".gpg" + att.MIMEType = "application/pgp-encrypted" + } else if err != nil && err != openpgperrors.ErrSignatureExpired { + err = fmt.Errorf("cannot decrypt attachment: %v", err) + return err + } + + // transfer encoding + ww := textwrapper.NewRFC822(w) + bw := base64.NewEncoder(base64.StdEncoding, ww) + + var n int64 + if n, err = io.Copy(bw, dr); err != nil { + err = fmt.Errorf("cannot write attachment: %v (wrote %v bytes)", err, n) + } + + _ = bw.Close() + return err +} + +func BuildEncrypted(m *pmapi.Message, readers []io.Reader, kr *crypto.KeyRing) ([]byte, error) { //nolint[funlen] + b := &bytes.Buffer{} + + // Overwrite content for main header for import. + // Even if message has just simple body we should upload as multipart/mixed. + // Each part has encrypted body and header reflects the original header. + mainHeader := GetHeader(m) + mainHeader.Set("Content-Type", "multipart/mixed; boundary="+GetBoundary(m)) + mainHeader.Del("Content-Disposition") + mainHeader.Del("Content-Transfer-Encoding") + if err := WriteHeader(b, mainHeader); err != nil { + return nil, err + } + mw := multipart.NewWriter(b) + if err := mw.SetBoundary(GetBoundary(m)); err != nil { + return nil, err + } + + // Write the body part. + bodyHeader := make(textproto.MIMEHeader) + bodyHeader.Set("Content-Type", m.MIMEType+"; charset=utf-8") + bodyHeader.Set("Content-Disposition", "inline") + bodyHeader.Set("Content-Transfer-Encoding", "7bit") + + p, err := mw.CreatePart(bodyHeader) + if err != nil { + return nil, err + } + // First, encrypt the message body. + if err := m.Encrypt(kr, kr); err != nil { + return nil, err + } + if _, err := io.WriteString(p, m.Body); err != nil { + return nil, err + } + + // Write the attachments parts. + for i := 0; i < len(m.Attachments); i++ { + att := m.Attachments[i] + r := readers[i] + h := GetAttachmentHeader(att) + p, err := mw.CreatePart(h) + if err != nil { + return nil, err + } + // Create line wrapper writer. + ww := textwrapper.NewRFC822(p) + + // Create base64 writer. + bw := base64.NewEncoder(base64.StdEncoding, ww) + + data, err := ioutil.ReadAll(r) + if err != nil { + return nil, err + } + + // Create encrypted writer. + pgpMessage, err := kr.Encrypt(crypto.NewPlainMessage(data), nil) + if err != nil { + return nil, err + } + if _, err := bw.Write(pgpMessage.GetBinary()); err != nil { + return nil, err + } + if err := bw.Close(); err != nil { + return nil, err + } + } + + if err := mw.Close(); err != nil { + return nil, err + } + + return b.Bytes(), nil +} diff --git a/pkg/message/message.go b/pkg/message/message.go index 2115112c..6093c180 100644 --- a/pkg/message/message.go +++ b/pkg/message/message.go @@ -23,8 +23,11 @@ import ( "strings" "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/sirupsen/logrus" ) +var log = logrus.WithField("pkg", "pkg/message") //nolint[gochecknoglobals] + func GetBoundary(m *pmapi.Message) string { // The boundary needs to be deterministic because messages are not supposed to // change. diff --git a/pkg/message/parser.go b/pkg/message/parser.go index ee7deea1..80da7ab8 100644 --- a/pkg/message/parser.go +++ b/pkg/message/parser.go @@ -37,7 +37,6 @@ import ( pmmime "github.com/ProtonMail/proton-bridge/pkg/mime" "github.com/ProtonMail/proton-bridge/pkg/pmapi" "github.com/jaytaylor/html2text" - log "github.com/sirupsen/logrus" ) func parseAttachment(filename string, mediaType string, h textproto.MIMEHeader) (att *pmapi.Attachment) { diff --git a/pkg/message/utils.go b/pkg/message/utils.go new file mode 100644 index 00000000..c66fb514 --- /dev/null +++ b/pkg/message/utils.go @@ -0,0 +1,85 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package message + +import ( + "bytes" + "html/template" + "io" + "net/http" + "net/mail" + "net/textproto" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" +) + +func WriteHeader(w io.Writer, h textproto.MIMEHeader) (err error) { + if err = http.Header(h).Write(w); err != nil { + return + } + _, err = io.WriteString(w, "\r\n") + return +} + +const customMessageTemplate = ` + + + +
+ Decryption error
+ Decryption of this message's encrypted content failed. +
{{.Error}}
+
+ + {{if .AttachBody}} +
+
{{.Body}}
+
+ {{- end}} + + +` + +type customMessageData struct { + Error string + AttachBody bool + Body string +} + +func CustomMessage(m *pmapi.Message, decodeError error, attachBody bool) error { + t := template.Must(template.New("customMessage").Parse(customMessageTemplate)) + + b := new(bytes.Buffer) + + if err := t.Execute(b, customMessageData{ + Error: decodeError.Error(), + AttachBody: attachBody, + Body: m.Body, + }); err != nil { + return err + } + + m.MIMEType = pmapi.ContentTypeHTML + m.Body = b.String() + + // NOTE: we need to set header in custom message header, so we check that is non-nil. + if m.Header == nil { + m.Header = make(mail.Header) + } + return nil +} diff --git a/pkg/pmapi/clientmanager.go b/pkg/pmapi/clientmanager.go index c4e44d80..b351347f 100644 --- a/pkg/pmapi/clientmanager.go +++ b/pkg/pmapi/clientmanager.go @@ -347,6 +347,14 @@ func (cm *ClientManager) CheckConnection() error { return nil } +// CheckConnection returns an error if there is no internet connection. +func CheckConnection() error { + client := &http.Client{Timeout: time.Second * 10} + retStatus := make(chan error) + checkConnection(client, "http://protonstatus.com/vpn_status", retStatus) + return <-retStatus +} + func checkConnection(client *http.Client, url string, errorChannel chan error) { resp, err := client.Get(url) if err != nil { diff --git a/pkg/pmapi/import.go b/pkg/pmapi/import.go index 2a7f9d96..804b464d 100644 --- a/pkg/pmapi/import.go +++ b/pkg/pmapi/import.go @@ -95,6 +95,11 @@ type ImportMsgReq struct { LabelIDs []string } +func (req ImportMsgReq) String() string { + data, _ := json.Marshal(req) + return string(data) +} + // ImportRes is a response to an import request. type ImportRes struct { Res diff --git a/pkg/updates/updates.go b/pkg/updates/updates.go index de58ce69..2b0a9607 100644 --- a/pkg/updates/updates.go +++ b/pkg/updates/updates.go @@ -120,7 +120,7 @@ func (u *Updates) CreateJSONAndSign(deployDir, goos string) error { return nil } -func (u *Updates) CheckIsBridgeUpToDate() (isUpToDate bool, latestVersion VersionInfo, err error) { +func (u *Updates) CheckIsUpToDate() (isUpToDate bool, latestVersion VersionInfo, err error) { localVersion := u.GetLocalVersion() latestVersion, err = u.getLatestVersion() if err != nil { diff --git a/pkg/updates/updates_test.go b/pkg/updates/updates_test.go index 4482c0e2..220864d1 100644 --- a/pkg/updates/updates_test.go +++ b/pkg/updates/updates_test.go @@ -71,14 +71,14 @@ func startServer() { func TestCheckBridgeIsUpToDate(t *testing.T) { updates := newTestUpdates("1.1.6") - isUpToDate, _, err := updates.CheckIsBridgeUpToDate() + isUpToDate, _, err := updates.CheckIsUpToDate() require.NoError(t, err) require.True(t, isUpToDate, "Bridge should be up to date") } func TestCheckBridgeIsNotUpToDate(t *testing.T) { updates := newTestUpdates("1.1.5") - isUpToDate, _, err := updates.CheckIsBridgeUpToDate() + isUpToDate, _, err := updates.CheckIsUpToDate() require.NoError(t, err) require.True(t, !isUpToDate, "Bridge should not be up to date") } diff --git a/release-notes/bugs.txt b/release-notes/bugs-bridge.txt similarity index 100% rename from release-notes/bugs.txt rename to release-notes/bugs-bridge.txt diff --git a/release-notes/bugs-importexport.txt b/release-notes/bugs-importexport.txt new file mode 100644 index 00000000..e69de29b diff --git a/release-notes/notes.txt b/release-notes/notes-bridge.txt similarity index 100% rename from release-notes/notes.txt rename to release-notes/notes-bridge.txt diff --git a/release-notes/notes-importexport.txt b/release-notes/notes-importexport.txt new file mode 100644 index 00000000..e69de29b diff --git a/utils/credits.sh b/utils/credits.sh index 7943c1ad..6827c7b0 100755 --- a/utils/credits.sh +++ b/utils/credits.sh @@ -1,6 +1,5 @@ #!/bin/bash - # Copyright (c) 2020 Proton Technologies AG # # This file is part of ProtonMail Bridge. @@ -20,6 +19,8 @@ ## Generate credits from go.mod +PACKAGE=$1 + # Vendor packages LOCKFILE=../go.mod egrep $'^\t[^=>]*$' $LOCKFILE | sed -r 's/\t([^ ]*) v.*/\1/g' > tmp1 @@ -30,6 +31,6 @@ echo -e "\nFont Awesome 4.7.0\n\nQt 5.13 by Qt group\n" >> tmp # join lines sed -i -e ':a' -e 'N' -e '$!ba' -e 's|\n|;|g' tmp -cat ../utils/license_header.txt > ../internal/bridge/credits.go -echo -e '// Code generated by '`echo $0`' at '`date`'. DO NOT EDIT.\n\npackage bridge\n\nconst Credits = "'$(cat tmp)'"' >> ../internal/bridge/credits.go +cat ../utils/license_header.txt > ../internal/$PACKAGE/credits.go +echo -e '// Code generated by '`echo $0`' at '`date`'. DO NOT EDIT.\n\npackage '$PACKAGE'\n\nconst Credits = "'$(cat tmp)'"' >> ../internal/$PACKAGE/credits.go rm tmp1 tmp diff --git a/utils/release-notes.sh b/utils/release-notes.sh index 019afdde..9ab5ff41 100755 --- a/utils/release-notes.sh +++ b/utils/release-notes.sh @@ -17,7 +17,8 @@ # You should have received a copy of the GNU General Public License # along with ProtonMail Bridge. If not, see . +PACKAGE=$1 # Generate release notes information -cat ../utils/license_header.txt > ../internal/bridge/release_notes.go -echo -e '// Code generated by '`echo $0`' at '`date`'. DO NOT EDIT.\n\npackage bridge\n\nconst ReleaseNotes = `'"$(cat ../release-notes/notes.txt)"'\n`\n\nconst ReleaseFixedBugs = `'"$(cat ../release-notes/bugs.txt)"'\n`' >> ../internal/bridge/release_notes.go +cat ../utils/license_header.txt > ../internal/$PACKAGE/release_notes.go +echo -e "// Code generated by `echo $0` at '`date`'. DO NOT EDIT.\n\npackage ${PACKAGE}\n\nconst ReleaseNotes = \``cat ../release-notes/notes-${PACKAGE}.txt`\n\`\n\nconst ReleaseFixedBugs = \``cat ../release-notes/bugs-${PACKAGE}.txt`\n\`" >> ../internal/$PACKAGE/release_notes.go From 49316a935ce8d423d64daa9989639e343705563b Mon Sep 17 00:00:00 2001 From: Jakub Date: Wed, 27 May 2020 15:58:50 +0200 Subject: [PATCH 02/22] Shared GUI for Bridge and Import/Export --- .gitignore | 30 +- BUILDS.md | 23 +- Changelog.md | 42 + Makefile | 63 +- README.md | 8 +- cmd/Desktop-Bridge/main.go | 2 +- cmd/Import-Export/main.go | 2 +- go.mod | 2 + go.sum | 8 + internal/frontend/frontend.go | 10 +- internal/frontend/qml/GuiIE.qml | 417 +++++++ .../qml/ImportExportUI/AccountDelegate.qml | 432 +++++++ .../frontend/qml/ImportExportUI/Credits.qml | 92 ++ .../frontend/qml/ImportExportUI/DateBox.qml | 220 ++++ .../frontend/qml/ImportExportUI/DateInput.qml | 243 ++++ .../frontend/qml/ImportExportUI/DateRange.qml | 121 ++ .../qml/ImportExportUI/DateRangeFunctions.qml | 81 ++ .../qml/ImportExportUI/DateRangeMenu.qml | 151 +++ .../qml/ImportExportUI/DialogExport.qml | 457 ++++++++ .../qml/ImportExportUI/DialogImport.qml | 1027 +++++++++++++++++ .../qml/ImportExportUI/DialogYesNo.qml | 354 ++++++ .../qml/ImportExportUI/ExportStructure.qml | 151 +++ .../qml/ImportExportUI/FilterStructure.qml | 55 + .../qml/ImportExportUI/FolderRowButton.qml | 99 ++ .../frontend/qml/ImportExportUI/HelpView.qml | 129 +++ .../frontend/qml/ImportExportUI/IEStyle.qml | 106 ++ .../qml/ImportExportUI/ImportDelegate.qml | 164 +++ .../qml/ImportExportUI/ImportReport.qml | 216 ++++ .../qml/ImportExportUI/ImportReportCell.qml | 67 ++ .../qml/ImportExportUI/ImportSourceButton.qml | 89 ++ .../qml/ImportExportUI/ImportStructure.qml | 149 +++ .../qml/ImportExportUI/InlineDateRange.qml | 128 ++ .../qml/ImportExportUI/InlineLabelSelect.qml | 227 ++++ .../qml/ImportExportUI/LabelIconList.qml | 96 ++ .../qml/ImportExportUI/MainWindow.qml | 473 ++++++++ .../qml/ImportExportUI/OutputFormat.qml | 84 ++ .../qml/ImportExportUI/PopupEditFolder.qml | 311 +++++ .../qml/ImportExportUI/SelectFolderMenu.qml | 362 ++++++ .../qml/ImportExportUI/SelectLabelsMenu.qml | 29 + .../qml/ImportExportUI/SettingsView.qml | 148 +++ .../qml/ImportExportUI/VersionInfo.qml | 115 ++ internal/frontend/qml/ImportExportUI/qmldir | 31 + .../frontend/qml/ProtonUI/AccountView.qml | 4 +- .../frontend/qml/ProtonUI/BugReportWindow.qml | 1 + .../frontend/qml/ProtonUI/CheckBoxLabel.qml | 2 + .../frontend/qml/ProtonUI/DialogUpdate.qml | 14 +- internal/frontend/qml/ProtonUI/InputField.qml | 16 + .../frontend/qml/ProtonUI/PopupMessage.qml | 56 +- .../qml/ProtonUI/RoundedRectangle.qml | 115 ++ internal/frontend/qml/ProtonUI/Style.qml | 25 + .../frontend/qml/ProtonUI/WindowTitleBar.qml | 4 +- internal/frontend/qml/ProtonUI/qmldir | 1 + internal/frontend/qml/tst_GuiIE.qml | 970 ++++++++++++++++ internal/frontend/qt-common/Makefile.local | 6 + internal/frontend/qt-common/account_model.go | 236 ++++ internal/frontend/qt-common/accounts.go | 259 +++++ .../{qt/logs.cpp => qt-common/common.cpp} | 21 +- internal/frontend/qt-common/common.go | 130 +++ .../{qt/logs.h => qt-common/common.h} | 5 +- internal/frontend/qt-common/notification.go | 40 + internal/frontend/qt-common/path_status.go | 81 ++ internal/frontend/qt-ie/Makefile.local | 60 + internal/frontend/qt-ie/README.md | 55 + internal/frontend/qt-ie/enums.go | 68 ++ internal/frontend/qt-ie/error_list.go | 129 +++ internal/frontend/qt-ie/export.go | 125 ++ internal/frontend/qt-ie/folder_functions.go | 539 +++++++++ internal/frontend/qt-ie/folder_structure.go | 196 ++++ .../frontend/qt-ie/folder_structure_test.go | 65 ++ internal/frontend/qt-ie/frontend.go | 497 ++++++++ internal/frontend/qt-ie/frontend_nogui.go | 55 + internal/frontend/qt-ie/import.go | 89 ++ .../{qt/logs.go => qt-ie/notification.go} | 24 +- internal/frontend/qt-ie/types.go | 25 + internal/frontend/qt-ie/ui.go | 189 +++ internal/frontend/qt/Makefile.local | 8 +- internal/frontend/qt/frontend.go | 22 +- internal/frontend/qt/resources.qrc | 77 -- internal/frontend/qt/ui.go | 2 +- internal/frontend/resources.qrc | 116 ++ .../frontend/share/icons/envelope_open.png | Bin 0 -> 22900 bytes internal/frontend/share/icons/folder_open.png | Bin 0 -> 8875 bytes internal/frontend/share/icons/ie.icns | Bin 0 -> 272787 bytes internal/frontend/share/icons/ie.ico | Bin 0 -> 569990 bytes internal/frontend/share/icons/ie.svg | 31 + internal/frontend/types/types.go | 2 + internal/importexport/credits.go | 4 +- internal/importexport/importexport.go | 27 + internal/store/user_mailbox.go | 14 +- internal/transfer/mailbox.go | 15 + internal/transfer/mailbox_test.go | 43 + internal/transfer/transfer.go | 4 +- pkg/pmapi/labels.go | 18 + pkg/pmapi/labels_test.go | 16 + utils/credits.sh | 14 +- utils/enums.sh | 149 +++ 96 files changed, 11469 insertions(+), 209 deletions(-) create mode 100644 internal/frontend/qml/GuiIE.qml create mode 100644 internal/frontend/qml/ImportExportUI/AccountDelegate.qml create mode 100644 internal/frontend/qml/ImportExportUI/Credits.qml create mode 100644 internal/frontend/qml/ImportExportUI/DateBox.qml create mode 100644 internal/frontend/qml/ImportExportUI/DateInput.qml create mode 100644 internal/frontend/qml/ImportExportUI/DateRange.qml create mode 100644 internal/frontend/qml/ImportExportUI/DateRangeFunctions.qml create mode 100644 internal/frontend/qml/ImportExportUI/DateRangeMenu.qml create mode 100644 internal/frontend/qml/ImportExportUI/DialogExport.qml create mode 100644 internal/frontend/qml/ImportExportUI/DialogImport.qml create mode 100644 internal/frontend/qml/ImportExportUI/DialogYesNo.qml create mode 100644 internal/frontend/qml/ImportExportUI/ExportStructure.qml create mode 100644 internal/frontend/qml/ImportExportUI/FilterStructure.qml create mode 100644 internal/frontend/qml/ImportExportUI/FolderRowButton.qml create mode 100644 internal/frontend/qml/ImportExportUI/HelpView.qml create mode 100644 internal/frontend/qml/ImportExportUI/IEStyle.qml create mode 100644 internal/frontend/qml/ImportExportUI/ImportDelegate.qml create mode 100644 internal/frontend/qml/ImportExportUI/ImportReport.qml create mode 100644 internal/frontend/qml/ImportExportUI/ImportReportCell.qml create mode 100644 internal/frontend/qml/ImportExportUI/ImportSourceButton.qml create mode 100644 internal/frontend/qml/ImportExportUI/ImportStructure.qml create mode 100644 internal/frontend/qml/ImportExportUI/InlineDateRange.qml create mode 100644 internal/frontend/qml/ImportExportUI/InlineLabelSelect.qml create mode 100644 internal/frontend/qml/ImportExportUI/LabelIconList.qml create mode 100644 internal/frontend/qml/ImportExportUI/MainWindow.qml create mode 100644 internal/frontend/qml/ImportExportUI/OutputFormat.qml create mode 100644 internal/frontend/qml/ImportExportUI/PopupEditFolder.qml create mode 100644 internal/frontend/qml/ImportExportUI/SelectFolderMenu.qml create mode 100644 internal/frontend/qml/ImportExportUI/SelectLabelsMenu.qml create mode 100644 internal/frontend/qml/ImportExportUI/SettingsView.qml create mode 100644 internal/frontend/qml/ImportExportUI/VersionInfo.qml create mode 100644 internal/frontend/qml/ImportExportUI/qmldir create mode 100644 internal/frontend/qml/ProtonUI/RoundedRectangle.qml create mode 100644 internal/frontend/qml/tst_GuiIE.qml create mode 100644 internal/frontend/qt-common/Makefile.local create mode 100644 internal/frontend/qt-common/account_model.go create mode 100644 internal/frontend/qt-common/accounts.go rename internal/frontend/{qt/logs.cpp => qt-common/common.cpp} (58%) create mode 100644 internal/frontend/qt-common/common.go rename internal/frontend/{qt/logs.h => qt-common/common.h} (71%) create mode 100644 internal/frontend/qt-common/notification.go create mode 100644 internal/frontend/qt-common/path_status.go create mode 100644 internal/frontend/qt-ie/Makefile.local create mode 100644 internal/frontend/qt-ie/README.md create mode 100644 internal/frontend/qt-ie/enums.go create mode 100644 internal/frontend/qt-ie/error_list.go create mode 100644 internal/frontend/qt-ie/export.go create mode 100644 internal/frontend/qt-ie/folder_functions.go create mode 100644 internal/frontend/qt-ie/folder_structure.go create mode 100644 internal/frontend/qt-ie/folder_structure_test.go create mode 100644 internal/frontend/qt-ie/frontend.go create mode 100644 internal/frontend/qt-ie/frontend_nogui.go create mode 100644 internal/frontend/qt-ie/import.go rename internal/frontend/{qt/logs.go => qt-ie/notification.go} (72%) create mode 100644 internal/frontend/qt-ie/types.go create mode 100644 internal/frontend/qt-ie/ui.go delete mode 100644 internal/frontend/qt/resources.qrc create mode 100644 internal/frontend/resources.qrc create mode 100644 internal/frontend/share/icons/envelope_open.png create mode 100644 internal/frontend/share/icons/folder_open.png create mode 100644 internal/frontend/share/icons/ie.icns create mode 100644 internal/frontend/share/icons/ie.ico create mode 100644 internal/frontend/share/icons/ie.svg create mode 100644 utils/enums.sh diff --git a/.gitignore b/.gitignore index 4f29a61e..f795287c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,23 +18,29 @@ coverage.html mem.pprof # Auto generated frontend -frontend/qml/BridgeUI/*.qmlc -frontend/qml/ProtonUI/*.qmlc -frontend/qml/ProtonUI/fontawesome.ttf -frontend/qml/ProtonUI/images +internal/frontend/qml/BridgeUI/*.qmlc +internal/frontend/qml/ImportExportUI/*.qmlc +internal/frontend/qml/ProtonUI/*.qmlc +internal/frontend/qml/ProtonUI/fontawesome.ttf +internal/frontend/qml/ProtonUI/images +internal/frontend/qml/ImportExportUI/images frontend/qml/*.qmlc # Build files bridge_darwin_*.tgz cmd/Desktop-Bridge/deploy -internal/frontend/qt/moc.cpp -internal/frontend/qt/moc.go -internal/frontend/qt/moc.h -internal/frontend/qt/moc_cgo_darwin_darwin_amd64.go -internal/frontend/qt/moc_moc.h -internal/frontend/qt/rcc.cpp -internal/frontend/qt/rcc_cgo_darwin_darwin_amd64.go +internal/frontend/qt*/moc.cpp +internal/frontend/qt*/moc.go +internal/frontend/qt*/moc.h +internal/frontend/qt*/moc_cgo_*.go +internal/frontend/qt*/moc_moc.h +internal/frontend/qt*/rcc.cpp +internal/frontend/qt*/rcc.qrc +internal/frontend/qt*/rcc_cgo_*.go + internal/frontend/rcc.cpp internal/frontend/rcc.qrc -internal/frontend/rcc_cgo_darwin_darwin_amd64.go +internal/frontend/rcc_cgo_*.go vendor-cache/ + +/main.go \ No newline at end of file diff --git a/BUILDS.md b/BUILDS.md index 79afae80..3a1db764 100644 --- a/BUILDS.md +++ b/BUILDS.md @@ -1,4 +1,4 @@ -# Building ProtonMail Bridge app +# Building ProtonMail Bridge and Import-Export app ## Prerequisites * Go 1.13 @@ -19,6 +19,8 @@ Otherwise, the sending of crash reports will be disabled. export MSYSTEM= ``` + +### Build Bridge * in project root run ```bash @@ -26,9 +28,22 @@ make build ``` * The result will be stored in `./cmd/Destop-Bridge/deploy/${GOOS}/` - * for `linux`, the binary will have the name of the project directory (e.g `bridge`) - * for `windows`, the binary will have the file extension `.exe` (e.g `bridge.exe`) - * for `darwin`, the application will be created with name of the project directory (e.g `bridge.app`) + * for `linux`, the binary will have the name of the project directory (e.g `proton-bridge`) + * for `windows`, the binary will have the file extension `.exe` (e.g `proton-bridge.exe`) + * for `darwin`, the application will be created with name of the project directory (e.g `proton-bridge.app`) + +### Build Import-Export +* in project root run + +```bash +make build-ie +``` + +* The result will be stored in `./cmd/Import-Export/deploy/${GOOS}/` + * for `linux`, the binary will have the name of the project directory (e.g `proton-bridge`) + * for `windows`, the binary will have the file extension `.exe` (e.g `proton-bridge.exe`) + * for `darwin`, the application will be created with name of the project directory (e.g `proton-bridge.app`) + ## Useful tests, lints and checks In order to be able to run following commands please install the development dependencies: diff --git a/Changelog.md b/Changelog.md index 15220207..05861d1c 100644 --- a/Changelog.md +++ b/Changelog.md @@ -60,6 +60,48 @@ Changelog [format](http://keepachangelog.com/en/1.0.0/) * GODT-280 Migrate to gopenpgp v2. * `Unlock()` call on pmapi-client unlocks both User keys and Address keys. * Salt is available via `AuthSalt()` method. +* GODT-394 Don't check SMTP message send time in integration tests. +* GODT-380 Adding IE GUI to Bridge repo +* GODT-380 Adding IE GUI to Bridge repo and building + * BR: extend functionality of PopupDialog + * BR: makefile APP_VERSION instead of BRIDGE_VERSION + * BR: use common logs function for Qt + * BR: change `go.progressDescription` to `string` + * IE: Rounded button has fa-icon + * IE: `Upgrade` → `Update` + * IE: Moving `AccountModel` to `qt-common` + * IE: Added `ReportBug` to `internal/importexport` + * IE: Added event watch in GUI + * IE: Removed `onLoginFinished` +* GODT-388 support for both bridge and import/export credentials by package users +* GODT-387 store factory to make store optional +* GODT-386 renamed bridge to general users and keep bridge only for bridge stuff +* GODT-308 better user error message when request is canceled +* GODT-312 validate recipient emails in send before asking for their public keys + +### Fixed +* GODT-356 Fix crash when removing account while mail client is fetching messages (regression from GODT-204). +* GODT-390 Don't logout user if AuthRefresh fails because internet was off. +* GODT-358 Bad timeouts with Alternative Routing. +* GODT-363 Drafts are not deleted when already created on webapp. +* GODT-390 Don't logout user if AuthRefresh fails because internet was off. +* GODT-341 Fixed flaky unittest for Store synchronization cooldown. +* Crash when failing to match necessary html element. +* Crash in message.combineParts when copying nil slice. +* Handle double charset better by using local ParseMediaType instead of mime.ParseMediaType. +* Don't remove log dir. +* GODT-422 Fix element not found (avoid listing credentials, prefer getting). +* GODT-404 Don't keep connections to proxy servers alive if user disables DoH. +* Ensure DoH is used at startup to load users for the initial auth. +* Issue causing deadlock when reloading users keys due to double-locking of a mutex. + +## [v1.2.7] Donghai-hotfix - beta (2020-05-07) + +### Added +* IMAP mailbox info update when new mailbox is created. +* GODT-72 Use ISO-8859-1 encoding if charset is not specified and it isn't UTF-8. + +### Changed * GODT-308 Better user error message when request is canceled. * GODT-162 User Agent does not contain bridge version, only client in format `client name/client version (os)`. * GODT-258 Update go-imap to v1. diff --git a/Makefile b/Makefile index e9592cff..0d959d64 100644 --- a/Makefile +++ b/Makefile @@ -3,19 +3,20 @@ export GO111MODULE=on # By default, the target OS is the same as the host OS, # but this can be overridden by setting TARGET_OS to "windows"/"darwin"/"linux". GOOS:=$(shell go env GOOS) +TARGET_CMD?=Desktop-Bridge TARGET_OS?=${GOOS} ## Build -.PHONY: build build-nogui check-has-go +.PHONY: build build-ie build-nogui build-ie-nogui check-has-go -BRIDGE_VERSION?=$(shell git describe --abbrev=0 --tags)-git +APP_VERSION?=$(shell git describe --abbrev=0 --tags)-git REVISION:=$(shell git rev-parse --short=10 HEAD) BUILD_TIME:=$(shell date +%FT%T%z) BUILD_TAGS?=pmapi_prod BUILD_FLAGS:=-tags='${BUILD_TAGS}' BUILD_FLAGS_NOGUI:=-tags='${BUILD_TAGS} nogui' -GO_LDFLAGS:=$(addprefix -X github.com/ProtonMail/proton-bridge/pkg/constants.,Version=${BRIDGE_VERSION} Revision=${REVISION} BuildTime=${BUILD_TIME}) +GO_LDFLAGS:=$(addprefix -X github.com/ProtonMail/proton-bridge/pkg/constants.,Version=${APP_VERSION} Revision=${REVISION} BuildTime=${BUILD_TIME}) ifneq "${BUILD_LDFLAGS}" "" GO_LDFLAGS+= ${BUILD_LDFLAGS} endif @@ -23,7 +24,7 @@ GO_LDFLAGS:=-ldflags '${GO_LDFLAGS}' BUILD_FLAGS+= ${GO_LDFLAGS} BUILD_FLAGS_NOGUI+= ${GO_LDFLAGS} -DEPLOY_DIR:=cmd/Desktop-Bridge/deploy +DEPLOY_DIR:=cmd/${TARGET_CMD}/deploy ICO_FILES:= EXE:=$(shell basename ${CURDIR}) @@ -36,13 +37,22 @@ ifeq "${TARGET_OS}" "darwin" EXE:=${EXE}.app/Contents/MacOS/${EXE} endif EXE_TARGET:=${DEPLOY_DIR}/${TARGET_OS}/${EXE} + TGZ_TARGET:=bridge_${TARGET_OS}_${REVISION}.tgz +ifeq "${TARGET_CMD}" "Import-Export" + TGZ_TARGET:=ie_${TARGET_OS}_${REVISION}.tgz +endif build: ${TGZ_TARGET} +build-ie: + TARGET_CMD=Import-Export $(MAKE) build build-nogui: - go build ${BUILD_FLAGS_NOGUI} -o Desktop-Bridge cmd/Desktop-Bridge/main.go + go build ${BUILD_FLAGS_NOGUI} -o ${TARGET_CMD} cmd/${TARGET_CMD}/main.go + +build-ie-nogui: + TARGET_CMD=Import-Export $(MAKE) build-nogui ${TGZ_TARGET}: ${DEPLOY_DIR}/${TARGET_OS} rm -f $@ @@ -74,9 +84,9 @@ endif ${EXE_TARGET}: check-has-go gofiles ${ICO_FILES} update-vendor rm -rf deploy ${TARGET_OS} ${DEPLOY_DIR} - cp cmd/Desktop-Bridge/main.go . + cp cmd/${TARGET_CMD}/main.go . qtdeploy ${BUILD_FLAGS} ${QT_BUILD_TARGET} - mv deploy cmd/Desktop-Bridge + mv deploy cmd/${TARGET_CMD} rm -rf ${TARGET_OS} main.go logo.ico: ./internal/frontend/share/icons/logo.ico @@ -213,7 +223,7 @@ gofiles: ./internal/bridge/credits.go ./internal/bridge/release_notes.go ./inter ## Run and debug -.PHONY: run run-qt run-qt-cli run-nogui run-nogui-cli run-debug qmlpreview qt-fronted-clean clean +.PHONY: run run-ie run-qt run-ie-qt run-qt-cli run-nogui run-ie-nogui run-nogui-cli run-debug run-qml-preview run-ie-qml-preview clean-fronted-qt clean-fronted-qt-ie clean-fronted-qt-common clean VERBOSITY?=debug-client RUN_FLAGS:=-m -l=${VERBOSITY} @@ -225,27 +235,42 @@ run-qt-cli: ${EXE_TARGET} PROTONMAIL_ENV=dev ./$< ${RUN_FLAGS} -c run-nogui: clean-vendor gofiles - PROTONMAIL_ENV=dev go run ${BUILD_FLAGS_NOGUI} cmd/Desktop-Bridge/main.go ${RUN_FLAGS} | tee last.log + PROTONMAIL_ENV=dev go run ${BUILD_FLAGS_NOGUI} cmd/${TARGET_CMD}/main.go ${RUN_FLAGS} | tee last.log run-nogui-cli: clean-vendor gofiles - PROTONMAIL_ENV=dev go run ${BUILD_FLAGS_NOGUI} cmd/Desktop-Bridge/main.go ${RUN_FLAGS} -c - -run-ie: - PROTONMAIL_ENV=dev go run ${BUILD_FLAGS_NOGUI} cmd/Import-Export/main.go ${RUN_FLAGS} -c + PROTONMAIL_ENV=dev go run ${BUILD_FLAGS_NOGUI} cmd/${TARGET_CMD}/main.go ${RUN_FLAGS} -c run-debug: - PROTONMAIL_ENV=dev dlv debug --build-flags "${BUILD_FLAGS_NOGUI}" cmd/Desktop-Bridge/main.go -- ${RUN_FLAGS} + PROTONMAIL_ENV=dev dlv debug --build-flags "${BUILD_FLAGS_NOGUI}" cmd/${TARGET_CMD}/main.go -- ${RUN_FLAGS} run-qml-preview: - make -C internal/frontend/qt -f Makefile.local qmlpreview + $(MAKE) -C internal/frontend/qt -f Makefile.local qmlpreview + +run-ie-qml-preview: + $(MAKE) -C internal/frontend/qt-ie -f Makefile.local qmlpreview + +run-ie: + TARGET_CMD=Import-Export $(MAKE) run +run-ie-nogui: + TARGET_CMD=Import-Export $(MAKE) run-nogui +run-ie-qt: + TARGET_CMD=Import-Export $(MAKE) run-qt clean-frontend-qt: - make -C internal/frontend/qt -f Makefile.local clean + $(MAKE) -C internal/frontend/qt -f Makefile.local clean -clean-vendor: clean-frontend-qt +clean-frontend-qt-ie: + $(MAKE) -C internal/frontend/qt-ie -f Makefile.local clean + +clean-frontend-qt-common: + $(MAKE) -C internal/frontend/qt-common -f Makefile.local clean + + +clean-vendor: clean-frontend-qt clean-frontend-qt-ie clean-frontend-qt-common rm -rf ./vendor -clean: clean-frontend-qt +clean: clean-vendor rm -rf vendor-cache rm -rf cmd/Desktop-Bridge/deploy - rm -f build last.log mem.pprof + rm -rf cmd/Import-Export/deploy + rm -f build last.log mem.pprof main.go rm -rf logo.ico icon.rc icon_windows.syso internal/frontend/qt/icon_windows.syso diff --git a/README.md b/README.md index 2d6f714e..059d018c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# ProtonMail Bridge +# ProtonMail Bridge and Import Export Copyright (c) 2020 Proton Technologies AG This repository holds the ProtonMail Bridge application. @@ -7,7 +7,7 @@ For licensing information see [COPYING](./COPYING.md). For contribution policy see [CONTRIBUTING](./CONTRIBUTING.md). -## Description +## Description Bridge ProtonMail Bridge for e-mail clients. When launched, Bridge will initialize local IMAP/SMTP servers and render @@ -24,6 +24,8 @@ background. More details [on the public website](https://protonmail.com/bridge). +## Description Import-Export +TODO ## Keychain You need to have a keychain in order to run the ProtonMail Bridge. On Mac or @@ -39,7 +41,7 @@ or - `BRIDGESTRICTMODE`: tells bridge to turn on `bbolt`'s "strict mode" which checks the database after every `Commit`. Set to `1` to enable. ### Dev build or run -- `BRIDGE_VERSION`: set the bridge app version used during testing or building +- `APP_VERSION`: set the bridge app version used during testing or building - `PROTONMAIL_ENV`: when set to `dev` it is not using Sentry to report crashes - `VERBOSITY`: set log level used during test time and by the makefile diff --git a/cmd/Desktop-Bridge/main.go b/cmd/Desktop-Bridge/main.go index 7b9050df..1c48218f 100644 --- a/cmd/Desktop-Bridge/main.go +++ b/cmd/Desktop-Bridge/main.go @@ -145,7 +145,7 @@ func (ph *panicHandler) HandlePanic() { } config.HandlePanic(ph.cfg, fmt.Sprintf("Recover: %v", r)) - frontend.HandlePanic() + frontend.HandlePanic("ProtonMail Bridge") *ph.err = cli.NewExitError("Panic and restart", 255) numberOfCrashes++ diff --git a/cmd/Import-Export/main.go b/cmd/Import-Export/main.go index 36f490f3..f605afc7 100644 --- a/cmd/Import-Export/main.go +++ b/cmd/Import-Export/main.go @@ -113,7 +113,7 @@ func (ph *panicHandler) HandlePanic() { } config.HandlePanic(ph.cfg, fmt.Sprintf("Recover: %v", r)) - frontend.HandlePanic() + frontend.HandlePanic("ProtonMail Import-Export") *ph.err = cli.NewExitError("Panic and restart", 255) numberOfCrashes++ diff --git a/go.mod b/go.mod index 167b7846..cb4c80ba 100644 --- a/go.mod +++ b/go.mod @@ -62,6 +62,8 @@ require ( github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/stretchr/testify v1.6.1 github.com/therecipe/qt v0.0.0-20200701200531-7f61353ee73e + github.com/therecipe/qt/internal/binding/files/docs/5.12.0 v0.0.0-20200603231648-26cdb75b6f22 // indirect + github.com/therecipe/qt/internal/binding/files/docs/5.13.0 v0.0.0-20200603231648-26cdb75b6f22 // indirect github.com/twinj/uuid v1.0.0 // indirect github.com/urfave/cli v1.22.4 go.etcd.io/bbolt v1.3.5 diff --git a/go.sum b/go.sum index d58e6f62..79d0f39d 100644 --- a/go.sum +++ b/go.sum @@ -178,6 +178,14 @@ github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/therecipe/qt v0.0.0-20200701200531-7f61353ee73e h1:G0DQ/TRQyrEZjtLlLwevFjaRiG8eeCMlq9WXQ2OO2bk= github.com/therecipe/qt v0.0.0-20200701200531-7f61353ee73e/go.mod h1:SUUR2j3aE1z6/g76SdD6NwACEpvCxb3fvG82eKbD6us= +github.com/therecipe/qt v0.0.0-20200126204426-5074eb6d8c41 h1:yBVcrpbaQYJBdKT2pxTdlL4hBE/eM4UPcyj9YpyvSok= +github.com/therecipe/qt v0.0.0-20200126204426-5074eb6d8c41/go.mod h1:SUUR2j3aE1z6/g76SdD6NwACEpvCxb3fvG82eKbD6us= +github.com/therecipe/qt v0.0.0-20200603231648-26cdb75b6f22 h1:UrNr8EZueA1eREFmG5gVHBeeOuwW2GbzI9VfdB5uK+c= +github.com/therecipe/qt/internal/binding/files/docs v0.0.0-20191019224306-1097424d656c h1:/VhcwU7WuFEVgDHZ9V8PIYAyYqQ6KNxFUjBMOf2aFZM= +github.com/therecipe/qt/internal/binding/files/docs/5.12.0 v0.0.0-20200603231648-26cdb75b6f22 h1:FumuOkCw78iheUI3eIYhAgtsj/0HQBAib/jXk1cslJw= +github.com/therecipe/qt/internal/binding/files/docs/5.12.0 v0.0.0-20200603231648-26cdb75b6f22/go.mod h1:7m8PDYDEtEVqfjoUQc2UrFqhG0CDmoVJjRlQxexndFc= +github.com/therecipe/qt/internal/binding/files/docs/5.13.0 v0.0.0-20200603231648-26cdb75b6f22 h1:aYzTBQ/hC6FtbaRnyylxlhbSGMPnyD5lAzVO3Ae6emA= +github.com/therecipe/qt/internal/binding/files/docs/5.13.0 v0.0.0-20200603231648-26cdb75b6f22/go.mod h1:mH55Ek7AZcdns5KPp99O0bg+78el64YCYWHiQKrOdt4= github.com/twinj/uuid v1.0.0 h1:fzz7COZnDrXGTAOHGuUGYd6sG+JMq+AoE7+Jlu0przk= github.com/twinj/uuid v1.0.0/go.mod h1:mMgcE1RHFUFqe5AfiwlINXisXfDGro23fWdPUfOMjRY= github.com/urfave/cli v1.22.4 h1:u7tSpNPPswAFymm8IehJhy4uJMlUuU/GmqSkvJ1InXA= diff --git a/internal/frontend/frontend.go b/internal/frontend/frontend.go index 33f660de..91a39c63 100644 --- a/internal/frontend/frontend.go +++ b/internal/frontend/frontend.go @@ -24,6 +24,7 @@ import ( "github.com/ProtonMail/proton-bridge/internal/frontend/cli" cliie "github.com/ProtonMail/proton-bridge/internal/frontend/cli-ie" "github.com/ProtonMail/proton-bridge/internal/frontend/qt" + qtie "github.com/ProtonMail/proton-bridge/internal/frontend/qt-ie" "github.com/ProtonMail/proton-bridge/internal/frontend/types" "github.com/ProtonMail/proton-bridge/internal/importexport" "github.com/ProtonMail/proton-bridge/pkg/config" @@ -42,12 +43,12 @@ type Frontend interface { } // HandlePanic handles panics which occur for users with GUI. -func HandlePanic() { +func HandlePanic(appName string) { notify := notificator.New(notificator.Options{ DefaultIcon: "../frontend/ui/icon/icon.png", - AppName: "ProtonMail Bridge", + AppName: appName, }) - _ = notify.Push("Fatal Error", "The ProtonMail Bridge has encountered a fatal error. ", "/frontend/icon/icon.png", notificator.UR_CRITICAL) + _ = notify.Push("Fatal Error", "The "+appName+" has encountered a fatal error. ", "/frontend/icon/icon.png", notificator.UR_CRITICAL) } // New returns initialized frontend based on `frontendType`, which can be `cli` or `qt`. @@ -118,7 +119,6 @@ func newImportExport( case "cli": return cliie.New(panicHandler, config, eventListener, updates, ie) default: - return cliie.New(panicHandler, config, eventListener, updates, ie) - //return qt.New(panicHandler, config, eventListener, updates, ie) + return qtie.New(version, buildVersion, panicHandler, config, eventListener, updates, ie) } } diff --git a/internal/frontend/qml/GuiIE.qml b/internal/frontend/qml/GuiIE.qml new file mode 100644 index 00000000..04c117c4 --- /dev/null +++ b/internal/frontend/qml/GuiIE.qml @@ -0,0 +1,417 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// This is main qml file + +import QtQuick 2.8 +import ImportExportUI 1.0 +import ProtonUI 1.0 + +// All imports from dynamic must be loaded before +import QtQuick.Window 2.2 +import QtQuick.Controls 2.1 +import QtQuick.Layouts 1.3 + +Item { + id: gui + property alias winMain: winMain + property bool isFirstWindow: true + property int warningFlags: 0 + + property var locale : Qt.locale("en_US") + property date netBday : new Date("1989-03-13T00:00:00") + property var allYears : getYearList(1970,(new Date()).getFullYear()) + property var allMonths : getMonthList(1,12) + property var allDays : getDayList(1,31) + + property var enums : JSON.parse('{"pathOK":1,"pathEmptyPath":2,"pathWrongPath":4,"pathNotADir":8,"pathWrongPermissions":16,"pathDirEmpty":32,"errUnknownError":0,"errEventAPILogout":1,"errUpdateAPI":2,"errUpdateJSON":3,"errUserAuth":4,"errQApplication":18,"errEmailExportFailed":6,"errEmailExportMissing":7,"errNothingToImport":8,"errEmailImportFailed":12,"errDraftImportFailed":13,"errDraftLabelFailed":14,"errEncryptMessageAttachment":15,"errEncryptMessage":16,"errNoInternetWhileImport":17,"errUnlockUser":5,"errSourceMessageNotSelected":19,"errCannotParseMail":5000,"errWrongLoginOrPassword":5001,"errWrongServerPathOrPort":5002,"errWrongAuthMethod":5003,"errIMAPFetchFailed":5004,"errLocalSourceLoadFailed":1000,"errPMLoadFailed":1001,"errRemoteSourceLoadFailed":1002,"errLoadAccountList":1005,"errExit":1006,"errRetry":1007,"errAsk":1008,"errImportFailed":1009,"errCreateLabelFailed":1010,"errCreateFolderFailed":1011,"errUpdateLabelFailed":1012,"errUpdateFolderFailed":1013,"errFillFolderName":1014,"errSelectFolderColor":1015,"errNoInternet":1016,"folderTypeSystem":"","folderTypeLabel":"label","folderTypeFolder":"folder","folderTypeExternal":"external","progressInit":"init","progressLooping":"looping","statusNoInternet":"noInternet","statusCheckingInternet":"internetCheck","statusNewVersionAvailable":"oldVersion","statusUpToDate":"upToDate","statusForceUpdate":"forceupdate"}') + + IEStyle{} + + MainWindow { + id: winMain + + visible : true + Component.onCompleted: { + winMain.showAndRise() + } + } + + BugReportWindow { + id:bugreportWin + clientVersion.visible: false + onPrefill : { + userAddress.text="" + if (accountsModel.count>0) { + var addressList = accountsModel.get(0).aliases.split(";") + if (addressList.length>0) { + userAddress.text = addressList[0] + } + } + } + } + + // Signals from Go + Connections { + target: go + + + + + + + + + + + + + + + + + onProcessFinished : { + winMain.dialogAddUser.hide() + winMain.dialogGlobal.hide() + } + onOpenManual : Qt.openUrlExternally("https://protonmail.com/support/categories/import-export/") + + onNotifyBubble : { + //go.highlightSystray() + winMain.bubleNote.text = message + winMain.bubleNote.place(tabIndex) + winMain.bubleNote.show() + winMain.showAndRise() + } + onBubbleClosed : { + if (winMain.updateState=="uptodate") { + //go.normalSystray() + } + } + + onSetConnectionStatus: { + go.isConnectionOK = isAvailable + if (go.isConnectionOK) { + if( winMain.updateState==gui.enums.statusNoInternet) { + go.setUpdateState(gui.enums.statusUpToDate) + } + } else { + go.setUpdateState(gui.enums.statusNoInternet) + } + } + + onRunCheckVersion : { + go.setUpdateState(gui.enums.statusUpToDate) + winMain.dialogGlobal.state=gui.enums.statusCheckingInternet + winMain.dialogGlobal.show() + go.isNewVersionAvailable(showMessage) + } + + onSetUpdateState : { + // once app is outdated prevent from state change + if (winMain.updateState != gui.enums.statusForceUpdate) { + winMain.updateState = updateState + } + } + + onSetAddAccountWarning : winMain.dialogAddUser.setWarning(message, 0) + + onNotifyVersionIsTheLatest : { + winMain.popupMessage.show( + qsTr("You have the latest version!", "todo") + ) + } + + onNotifyError : { + var name = go.errorDescription.slice(0, go.errorDescription.indexOf("\n")) + var errorMessage = go.errorDescription.slice(go.errorDescription.indexOf("\n")) + switch (errCode) { + case gui.enums.errPMLoadFailed : + winMain.popupMessage.show ( qsTr ( "Loading ProtonMail folders and labels was not successful." , "Error message" ) ) + winMain.dialogExport.hide() + break + case gui.enums.errLocalSourceLoadFailed : + winMain.popupMessage.show(qsTr( + "Loading local folder structure was not successful. "+ + "Folder does not contain valid MBOX or EML file.", + "Error message when can not find correct files in folder." + )) + winMain.dialogImport.hide() + break + case gui.enums.errRemoteSourceLoadFailed : + winMain.popupMessage.show ( qsTr ( "Loading remote source structure was not successful." , "Error message" ) ) + winMain.dialogImport.hide() + break + case gui.enums.errWrongServerPathOrPort : + winMain.popupMessage.show ( qsTr ( "Cannot contact server - incorrect server address and port." , "Error message" ) ) + winMain.dialogImport.decrementCurrentIndex() + break + case gui.enums.errWrongLoginOrPassword : + winMain.popupMessage.show ( qsTr ( "Cannot authenticate - Incorrect email or password." , "Error message" ) ) + winMain.dialogImport.decrementCurrentIndex() + break ; + case gui.enums.errWrongAuthMethod : + winMain.popupMessage.show ( qsTr ( "Cannot authenticate - Please use secured authentication method." , "Error message" ) ) + winMain.dialogImport.decrementCurrentIndex() + break ; + + + case gui.enums.errFillFolderName: + winMain.popupMessage.show(qsTr ( + "Please fill the name.", + "Error message when user did not fill the name of folder or label" + )) + break + case gui.enums.errCreateLabelFailed: + winMain.popupMessage.show(qsTr( + "Cannot create label with name \"%1\"\n%2", + "Error message when it is not possible to create new label, arg1 folder name, arg2 error reason" + ).arg(name).arg(errorMessage)) + break + case gui.enums.errCreateFolderFailed: + winMain.popupMessage.show(qsTr( + "Cannot create folder with name \"%1\"\n%2", + "Error message when it is not possible to create new folder, arg1 folder name, arg2 error reason" + ).arg(name).arg(errorMessage)) + break + + case gui.enums.errNothingToImport: + winMain.popupMessage.show ( qsTr ( "No emails left to import after date range applied. Please, change the date range to continue." , "Error message" ) ) + winMain.dialogImport.decrementCurrentIndex() + break + + case gui.enums.errNoInternetWhileImport: + case gui.enums.errNoInternet: + go.setConnectionStatus(false) + winMain.popupMessage.show ( go.canNotReachAPI ) + break + + case gui.enums.errPMAPIMessageTooLarge: + case gui.enums.errIMAPFetchFailed: + case gui.enums.errEmailImportFailed : + case gui.enums.errDraftImportFailed : + case gui.enums.errDraftLabelFailed : + case gui.enums.errEncryptMessageAttachment: + case gui.enums.errEncryptMessage: + //winMain.dialogImport.ask_retry_skip_cancel(name, errorMessage) + console.log("Import error", errCode, go.errorDescription) + winMain.popupMessage.show(qsTr("Error during import: \n%1\n please see log files for more details.", "message of generic error").arg(go.errorDescription)) + winMain.dialogImport.hide() + break; + + case gui.enums.errUnknownError : default: + console.log("Unknown Error", errCode, go.errorDescription) + winMain.popupMessage.show(qsTr("The program encounter an unknown error \n%1\n please see log files for more details.", "message of generic error").arg(go.errorDescription)) + winMain.dialogExport.hide() + winMain.dialogImport.hide() + winMain.dialogAddUser.hide() + winMain.dialogGlobal.hide() + } + } + + onNotifyUpdate : { + go.setUpdateState("forceUpdate") + if (!winMain.dialogUpdate.visible) { + gui.openMainWindow(true) + go.runCheckVersion(false) + winMain.dialogUpdate.show() + } + } + + onNotifyLogout : { + go.notifyBubble(0, qsTr("Account %1 has been disconnected. Please log in to continue to use the Import-Export with this account.").arg(accname) ) + } + + onNotifyAddressChanged : { + go.notifyBubble(0, qsTr("The address list has been changed for account %1. You may need to reconfigure the settings in your email client.").arg(accname) ) + } + + onNotifyAddressChangedLogout : { + go.notifyBubble(0, qsTr("The address list has been changed for account %1. You have to reconfigure the settings in your email client.").arg(accname) ) + } + + + onNotifyKeychainRebuild : { + go.notifyBubble(1, qsTr( + "Your MacOS keychain is probably corrupted. Please consult the instructions in our FAQ.", + "notification message" + )) + } + + onNotifyHasNoKeychain : { + gui.winMain.dialogGlobal.state="noKeychain" + gui.winMain.dialogGlobal.show() + } + + + onExportStructureLoadFinished: { + if (okay) winMain.dialogExport.okay() + else winMain.dialogExport.cancel() + } + onImportStructuresLoadFinished: { + if (okay) winMain.dialogImport.okay() + else winMain.dialogImport.cancel() + } + + onSimpleErrorHappen: { + if (winMain.dialogImport.visible == true) { + winMain.dialogImport.hide() + } + if (winMain.dialogExport.visible == true) { + winMain.dialogExport.hide() + } + } + } + + function folderIcon(folderName, folderType) { // translations + switch (folderName.toLowerCase()) { + case "inbox" : return Style.fa.inbox + case "sent" : return Style.fa.send + case "spam" : + case "junk" : return Style.fa.ban + case "draft" : return Style.fa.file_o + case "starred" : return Style.fa.star_o + case "trash" : return Style.fa.trash_o + case "archive" : return Style.fa.archive + default: return folderType == gui.enums.folderTypeLabel ? Style.fa.tag : Style.fa.folder_open + } + return Style.fa.sticky_note_o + } + + function folderTypeTitle(folderType) { // translations + if (folderType==gui.enums.folderTypeSystem ) return "" + if (folderType==gui.enums.folderTypeLabel ) return qsTr("Labels" , "todo") + if (folderType==gui.enums.folderTypeFolder ) return qsTr("Folders" , "todo") + return "Undef" + } + + function isFolderEmpty() { + return "true" + } + + function getUnixTime(dateString) { + var d = new Date(dateString) + var n = d.getTime() + if (n != n) return -1 + return n + } + + function getYearList(minY,maxY) { + var years = new Array() + for (var i=0; i<=maxY-minY;i++) { + years[i] = (maxY-i).toString() + } + //console.log("getYearList:", years) + return years + } + + function getMonthList(minM,maxM) { + var months = new Array() + for (var i=0; i<=maxM-minM;i++) { + var iMonth = new Date(1989,(i+minM-1),13) + months[i] = iMonth.toLocaleString(gui.locale, "MMM") + } + //console.log("getMonthList:", months[0], months) + return months + } + + function getDayList(minD,maxD) { + var days = new Array() + for (var i=0; i<=maxD-minD;i++) { + days[i] = gui.prependZeros(i+minD,2) + } + return days + } + + function prependZeros(num,desiredLength) { + var s = num+"" + while (s.length < desiredLength) s="0"+s + return s + } + + function daysInMonth(year,month) { + if (typeof(year) !== 'number') { + year = parseInt(year) + } + if (typeof(month) !== 'number') { + month = Date.fromLocaleDateString( gui.locale, "1970-"+month+"-10", "yyyy-MMM-dd").getMonth()+1 + } + var maxDays = (new Date(year,month,0)).getDate() + if (isNaN(maxDays)) maxDays = 0 + //console.log(" daysInMonth", year, month, maxDays) + return maxDays + } + + function niceDateTime() { + var stamp = new Date() + var nice = getMonthList(stamp.getMonth()+1, stamp.getMonth()+1)[0] + nice += "-" + getDayList(stamp.getDate(), stamp.getDate())[0] + nice += "-" + getYearList(stamp.getFullYear(), stamp.getFullYear())[0] + nice += " " + gui.prependZeros(stamp.getHours(),2) + nice += ":" + gui.prependZeros(stamp.getMinutes(),2) + return nice + } + + /* + // Debug + Connections { + target: structureExternal + + onDataChanged: { + console.log("external data changed") + } + } + + // Debug + Connections { + target: structurePM + + onSelectedLabelsChanged: console.log("PM sel labels:", structurePM.selectedLabels) + onSelectedFoldersChanged: console.log("PM sel folders:", structurePM.selectedFolders) + onDataChanged: { + console.log("PM data changed") + } + } + */ + + Timer { + id: checkVersionTimer + repeat : true + triggeredOnStart: false + interval : Style.main.verCheckRepeatTime + onTriggered : go.runCheckVersion(false) + } + + property string areYouSureYouWantToQuit : qsTr("Tool does not finished all the jobs. Do you really want to quit?") + // On start + Component.onCompleted : { + // set spell messages + go.wrongCredentials = qsTr("Incorrect username or password." , "notification", -1) + go.wrongMailboxPassword = qsTr("Incorrect mailbox password." , "notification", -1) + go.canNotReachAPI = qsTr("Cannot contact server, please check your internet connection." , "notification", -1) + go.versionCheckFailed = qsTr("Version check was unsuccessful. Please try again later." , "notification", -1) + go.credentialsNotRemoved = qsTr("Credentials could not be removed." , "notification", -1) + go.bugNotSent = qsTr("Unable to submit bug report." , "notification", -1) + go.bugReportSent = qsTr("Bug report successfully sent." , "notification", -1) + + go.runCheckVersion(false) + checkVersionTimer.start() + + gui.allMonths = getMonthList(1,12) + gui.allMonthsChanged() + } +} diff --git a/internal/frontend/qml/ImportExportUI/AccountDelegate.qml b/internal/frontend/qml/ImportExportUI/AccountDelegate.qml new file mode 100644 index 00000000..caa6d490 --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/AccountDelegate.qml @@ -0,0 +1,432 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +import QtQuick 2.8 +import ProtonUI 1.0 +import ImportExportUI 1.0 + +// NOTE: Keep the Column so the height and width is inherited from content +Column { + id: root + state: status + anchors.left: parent.left + + property real row_width: 50 * Style.px + property int row_height: Style.accounts.heightAccount + property var listalias : aliases.split(";") + property int iAccount: index + + property real spacingLastButtons: (row_width - exportAccount.anchors.leftMargin -Style.main.rightMargin - exportAccount.width - logoutAccount.width - deleteAccount.width)/2 + + Accessible.role: go.goos=="windows" ? Accessible.Grouping : Accessible.Row + Accessible.name: qsTr("Account %1, status %2", "Accessible text describing account row with arguments: account name and status (connected/disconnected), resp.").arg(account).arg(statusMark.text) + Accessible.description: Accessible.name + Accessible.ignored: !enabled || !visible + + // Main row + Rectangle { + id: mainaccRow + anchors.left: parent.left + width : row_width + height : row_height + state: { return isExpanded ? "expanded" : "collapsed" } + color: Style.main.background + + property string actionName : ( + isExpanded ? + qsTr("Collapse row for account %2", "Accessible text of button showing additional configuration of account") : + qsTr("Expand row for account %2", "Accessible text of button hiding additional configuration of account") + ). arg(account) + + + // override by other buttons + MouseArea { + id: mouseArea + anchors.fill: parent + onClicked : { + if (root.state=="connected") { + mainaccRow.toggle_accountSettings() + } + } + cursorShape : root.state == "connected" ? Qt.PointingHandCursor : Qt.ArrowCursor + hoverEnabled: true + onEntered: { + if (mainaccRow.state=="collapsed") { + mainaccRow.color = Qt.lighter(Style.main.background,1.1) + } + } + onExited: { + if (mainaccRow.state=="collapsed") { + mainaccRow.color = Style.main.background + } + } + } + + // toggle down/up icon + Text { + id: toggleIcon + anchors { + left : parent.left + verticalCenter : parent.verticalCenter + leftMargin : Style.main.leftMargin + } + color: Style.main.text + font { + pointSize : Style.accounts.sizeChevron * Style.pt + family : Style.fontawesome.name + } + text: Style.fa.chevron_down + + MouseArea { + anchors.fill: parent + onClicked : { + if (root.state=="connected") { + mainaccRow.toggle_accountSettings() + } + } + cursorShape : root.state == "connected" ? Qt.PointingHandCursor : Qt.ArrowCursor + Accessible.role: Accessible.Button + Accessible.name: mainaccRow.actionName + Accessible.description: mainaccRow.actionName + Accessible.onPressAction : { + if (root.state=="connected") { + mainaccRow.toggle_accountSettings() + } + } + Accessible.ignored: root.state!="connected" || !root.enabled + } + } + + // account name + TextMetrics { + id: accountMetrics + font : accountName.font + elide: Qt.ElideMiddle + elideWidth: ( + statusMark.anchors.leftMargin + - toggleIcon.anchors.leftMargin + ) + text: account + } + Text { + id: accountName + anchors { + verticalCenter : parent.verticalCenter + left : toggleIcon.left + leftMargin : Style.main.leftMargin + } + color: Style.main.text + font { + pointSize : (Style.main.fontSize+2*Style.px) * Style.pt + } + text: accountMetrics.elidedText + } + + // status + ClickIconText { + id: statusMark + anchors { + verticalCenter : parent.verticalCenter + left : parent.left + leftMargin : row_width/2 + } + text : qsTr("connected", "status of a listed logged-in account") + iconText : Style.fa.circle_o + textColor : Style.main.textGreen + enabled : false + Accessible.ignored: true + } + + // export + ClickIconText { + id: exportAccount + anchors { + verticalCenter : parent.verticalCenter + left : parent.left + leftMargin : 5.5*row_width/8 + } + text : qsTr("Export All", "todo") + iconText : Style.fa.floppy_o + textBold : true + textColor : Style.main.textBlue + onClicked: { + dialogExport.currentIndex = 0 + dialogExport.address = account + dialogExport.show() + } + } + + // logout + ClickIconText { + id: logoutAccount + anchors { + verticalCenter : parent.verticalCenter + left : exportAccount.right + leftMargin : root.spacingLastButtons + } + text : qsTr("Log out", "action to log out a connected account") + iconText : Style.fa.power_off + textBold : true + textColor : Style.main.textBlue + } + + // remove + ClickIconText { + id: deleteAccount + anchors { + verticalCenter : parent.verticalCenter + left : logoutAccount.right + leftMargin : root.spacingLastButtons + } + text : qsTr("Remove", "deletes an account from the account settings page") + iconText : Style.fa.trash_o + textColor : Style.main.text + onClicked : { + dialogGlobal.input=iAccount + dialogGlobal.state="deleteUser" + dialogGlobal.show() + } + } + + + // functions + function toggle_accountSettings() { + if (root.state=="connected") { + if (mainaccRow.state=="collapsed" ) { + mainaccRow.state="expanded" + } else { + mainaccRow.state="collapsed" + } + } + } + + states: [ + State { + name: "collapsed" + PropertyChanges { target : toggleIcon ; text : root.state=="connected" ? Style.fa.chevron_down : " " } + PropertyChanges { target : accountName ; font.bold : false } + PropertyChanges { target : mainaccRow ; color : Style.main.background } + PropertyChanges { target : addressList ; visible : false } + }, + State { + name: "expanded" + PropertyChanges { target : toggleIcon ; text : Style.fa.chevron_up } + PropertyChanges { target : accountName ; font.bold : true } + PropertyChanges { target : mainaccRow ; color : Style.accounts.backgroundExpanded } + PropertyChanges { target : addressList ; visible : true } + } + ] + } + + // List of adresses + Column { + id: addressList + anchors.left : parent.left + width: row_width + visible: false + property alias model : repeaterAddresses.model + + Repeater { + id: repeaterAddresses + model: ["one", "two"] + + Rectangle { + id: addressRow + anchors { + left : parent.left + right : parent.right + } + height: Style.accounts.heightAddrRow + color: Style.accounts.backgroundExpanded + + // iconText level down + Text { + id: levelDown + anchors { + left : parent.left + leftMargin : Style.accounts.leftMarginAddr + verticalCenter : wrapAddr.verticalCenter + } + text : Style.fa.level_up + font.family : Style.fontawesome.name + color : Style.main.textDisabled + rotation : 90 + } + + Rectangle { + id: wrapAddr + anchors { + top : parent.top + left : levelDown.right + right : parent.right + leftMargin : Style.main.leftMargin + rightMargin : Style.main.rightMargin + } + height: Style.accounts.heightAddr + border { + width : Style.main.border + color : Style.main.line + } + color: Style.accounts.backgroundAddrRow + + TextMetrics { + id: addressMetrics + font: address.font + elideWidth: ( + wrapAddr.width + - address.anchors.leftMargin + - 2*exportAlias.width + - 3*exportAlias.anchors.rightMargin + ) + elide: Qt.ElideMiddle + text: modelData + } + + Text { + id: address + anchors { + verticalCenter : parent.verticalCenter + left: parent.left + leftMargin: Style.main.leftMargin + } + font.pointSize : Style.main.fontSize * Style.pt + color: Style.main.text + text: addressMetrics.elidedText + } + + // export + ClickIconText { + id: exportAlias + anchors { + verticalCenter: parent.verticalCenter + right: importAlias.left + rightMargin: Style.main.rightMargin + } + text: qsTr("Export", "todo") + iconText: Style.fa.floppy_o + textBold: true + textColor: Style.main.textBlue + onClicked: { + dialogExport.address = listalias[index] + dialogExport.show() + } + } + + // import + ClickIconText { + id: importAlias + anchors { + verticalCenter: parent.verticalCenter + right: parent.right + rightMargin: Style.main.rightMargin + } + text: qsTr("Import", "todo") + iconText: Style.fa.upload + textBold: true + textColor: enabled ? Style.main.textBlue : Style.main.textDisabled + onClicked: { + dialogImport.address = listalias[index] + dialogImport.show() + } + } + } + } + } + } + + // line + Rectangle { + id: line + color: Style.accounts.line + height: Style.accounts.heightLine + width: root.row_width + } + + + states: [ + State { + name: "connected" + PropertyChanges { + target : addressList + model : listalias + } + PropertyChanges { + target : toggleIcon + color : Style.main.text + } + PropertyChanges { + target : accountName + color : Style.main.text + } + PropertyChanges { + target : statusMark + textColor : Style.main.textGreen + text : qsTr("connected", "status of a listed logged-in account") + iconText : Style.fa.circle + } + PropertyChanges { + target: exportAccount + visible: true + } + PropertyChanges { + target : logoutAccount + text : qsTr("Log out", "action to log out a connected account") + onClicked : { + mainaccRow.state="collapsed" + dialogGlobal.state = "logout" + dialogGlobal.input = root.iAccount + dialogGlobal.show() + dialogGlobal.confirmed() + } + } + }, + State { + name: "disconnected" + PropertyChanges { + target : addressList + model : 0 + } + PropertyChanges { + target : toggleIcon + color : Style.main.textDisabled + } + PropertyChanges { + target : accountName + color : Style.main.textDisabled + } + PropertyChanges { + target : statusMark + textColor : Style.main.textDisabled + text : qsTr("disconnected", "status of a listed logged-out account") + iconText : Style.fa.circle_o + } + PropertyChanges { + target : logoutAccount + text : qsTr("Log in", "action to log in a disconnected account") + onClicked : { + dialogAddUser.username = root.listalias[0] + dialogAddUser.show() + dialogAddUser.inputPassword.focusInput = true + } + } + PropertyChanges { + target: exportAccount + visible: false + } + } + ] +} diff --git a/internal/frontend/qml/ImportExportUI/Credits.qml b/internal/frontend/qml/ImportExportUI/Credits.qml new file mode 100644 index 00000000..ef85265a --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/Credits.qml @@ -0,0 +1,92 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// credits + +import QtQuick 2.8 +import ProtonUI 1.0 +import ImportExportUI 1.0 + +Item { + id: root + Rectangle { + anchors.centerIn: parent + width: Style.main.width + height: root.parent.height - 6*Style.dialog.titleSize + color: "transparent" + + ListView { + anchors.fill: parent + clip: true + + model: [ + "github.com/0xAX/notificator" , + "github.com/abiosoft/ishell" , + "github.com/allan-simon/go-singleinstance" , + "github.com/andybalholm/cascadia" , + "github.com/bgentry/speakeasy" , + "github.com/boltdb/bolt" , + "github.com/docker/docker-credential-helpers" , + "github.com/emersion/go-imap" , + "github.com/emersion/go-imap-appendlimit" , + "github.com/emersion/go-imap-idle" , + "github.com/emersion/go-imap-move" , + "github.com/emersion/go-imap-quota" , + "github.com/emersion/go-imap-specialuse" , + "github.com/emersion/go-smtp" , + "github.com/emersion/go-textwrapper" , + "github.com/fsnotify/fsnotify" , + "github.com/jaytaylor/html2text" , + "github.com/jhillyerd/go.enmime" , + "github.com/k0kubun/pp" , + "github.com/kardianos/osext" , + "github.com/keybase/go-keychain" , + "github.com/mattn/go-colorable" , + "github.com/pkg/browser" , + "github.com/shibukawa/localsocket" , + "github.com/shibukawa/tobubus" , + "github.com/shirou/gopsutil" , + "github.com/sirupsen/logrus" , + "github.com/skratchdot/open-golang/open" , + "github.com/therecipe/qt" , + "github.com/thomasf/systray" , + "github.com/ugorji/go/codec" , + "github.com/urfave/cli" , + "" , + "Font Awesome 4.7.0", + "" , + "The Qt Company - Qt 5.9.1 LGPLv3" , + "" , + ] + + delegate: Text { + anchors.horizontalCenter: parent.horizontalCenter + text: modelData + color: Style.main.text + } + + footer: ButtonRounded { + anchors.horizontalCenter: parent.horizontalCenter + text: "Close" + onClicked: { + root.parent.hide() + } + } + } + } +} + diff --git a/internal/frontend/qml/ImportExportUI/DateBox.qml b/internal/frontend/qml/ImportExportUI/DateBox.qml new file mode 100644 index 00000000..b7b564d8 --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/DateBox.qml @@ -0,0 +1,220 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// input for year / month / day +import QtQuick 2.8 +import QtQuick.Controls 2.2 +import QtQml.Models 2.2 +import ProtonUI 1.0 +import ImportExportUI 1.0 + +ComboBox { + id: root + + property string placeholderText : "none" + property var dropDownStyle : Style.dropDownLight + property real radius : Style.dialog.radiusButton + property bool below : true + + onDownChanged : { + root.below = popup.y>0 + } + + + font.pointSize : Style.main.fontSize * Style.pt + + spacing : Style.dialog.spacing + height : Style.dialog.heightInput + width : 10*Style.px + + function updateWidth() { + // make the width according to localization ( especially for Months) + var max = 10*Style.px + if (root.model === undefined) return + for (var i=-1; i=0 ? root.displayText : placeholderText + font : root.font + color : root.enabled ? dropDownStyle.text : dropDownStyle.inactive + verticalAlignment : Text.AlignVCenter + elide : Text.ElideRight + } + + background: Rectangle { + color: Style.transparent + + MouseArea { + anchors.fill: parent + onClicked: root.down ? root.popup.close() : root.popup.open() + } + } + + + DelegateModel { // FIXME QML DelegateModel: Error creating delegate + id: filteredData + model: root.model + filterOnGroup: "filtered" + groups: DelegateModelGroup { + id: filtered + name: "filtered" + includeByDefault: true + } + delegate: root.delegate + } + + function filterItems(minIndex,maxIndex) { + // filter + var rowCount = filteredData.items.count + if (rowCount<=0) return + //console.log(" filter", root.placeholderText, rowCount, minIndex, maxIndex) + for (var iItem = 0; iItem < rowCount; iItem++) { + var entry = filteredData.items.get(iItem); + entry.inFiltered = ( iItem >= minIndex && iItem <= maxIndex ) + //console.log(" inserted ", iItem, rowCount, entry.model.modelData, entry.inFiltered ) + } + } + + delegate: ItemDelegate { + id: thisItem + width : view.width + height : Style.dialog.heightInput + leftPadding : root.spacing + rightPadding : root.spacing + topPadding : 0 + bottomPadding : 0 + + property int index : { + //console.log( "index: ", thisItem.DelegateModel.itemsIndex ) + return thisItem.DelegateModel.itemsIndex + } + + onClicked : { + //console.log("thisItem click", thisItem.index) + root.currentIndex = thisItem.index + root.activated(thisItem.index) + root.popup.close() + } + + + contentItem: Text { + text: modelData + color: dropDownStyle.text + font: root.font + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + color: thisItem.hovered ? dropDownStyle.highlight : dropDownStyle.background + Text { + anchors{ + right: parent.right + rightMargin: root.spacing + verticalCenter: parent.verticalCenter + } + font { + family: Style.fontawesome.name + } + text: root.currentIndex == thisItem.index ? Style.fa.check : "" + color: thisItem.hovered ? dropDownStyle.text : dropDownStyle.highlight + } + + Rectangle { + anchors { + left: parent.left + right: parent.right + bottom: parent.bottom + } + height: Style.dialog.borderInput + color: dropDownStyle.separator + } + } + } + + popup: Popup { + y: root.height + x: -background.strokeWidth + width: root.width + 2*background.strokeWidth + modal: true + closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape + topPadding: background.radiusTopLeft + 2*background.strokeWidth + bottomPadding: background.radiusBottomLeft + 2*background.strokeWidth + leftPadding: 2*background.strokeWidth + rightPadding: 2*background.strokeWidth + + contentItem: ListView { + id: view + clip: true + implicitHeight: winMain.height/3 + model: filteredData // if you want to slide down to position: popup.visible ? root.delegateModel : null + currentIndex: root.currentIndex + + ScrollIndicator.vertical: ScrollIndicator { } + } + + background: RoundedRectangle { + radiusTopLeft : root.below ? 0 : root.radius + radiusBottomLeft : !root.below ? 0 : root.radius + radiusTopRight : radiusTopLeft + radiusBottomRight : radiusBottomLeft + fillColor : dropDownStyle.background + } + } + + Component.onCompleted: { + //console.log(" box ", label) + root.updateWidth() + root.filterItems(0,model.length-1) + } + + onModelChanged :{ + //console.log("model changed", root.placeholderText) + root.updateWidth() + root.filterItems(0,model.length-1) + } +} + diff --git a/internal/frontend/qml/ImportExportUI/DateInput.qml b/internal/frontend/qml/ImportExportUI/DateInput.qml new file mode 100644 index 00000000..b9b78a9b --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/DateInput.qml @@ -0,0 +1,243 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// input for date +import QtQuick 2.8 +import QtQuick.Controls 2.2 +import ProtonUI 1.0 +import ImportExportUI 1.0 + +Rectangle { + id: root + + width : row.width + (root.label == "" ? 0 : textlabel.width) + height : row.height + color : Style.transparent + + property alias label : textlabel.text + property string metricsLabel : root.label + property var dropDownStyle : Style.dropDownLight + + // dates + property date currentDate : new Date() // default now + property date minDate : new Date(0) // default epoch start + property date maxDate : new Date() // default now + property int unix : Math.floor(currentDate.getTime()/1000) + + onMinDateChanged: { + if (isNaN(minDate.getTime()) || minDate.getTime() > maxDate.getTime()) { + minDate = new Date(0) + } + //console.log(" minDate changed:", root.label, minDate.toDateString()) + updateRange() + } + onMaxDateChanged: { + if (isNaN(maxDate.getTime()) || minDate.getTime() > maxDate.getTime()) { + maxDate = new Date() + } + //console.log(" maxDate changed:", root.label, maxDate.toDateString()) + updateRange() + } + + RoundedRectangle { + id: background + anchors.fill : row + strokeColor : dropDownStyle.line + strokeWidth : Style.dialog.borderInput + fillColor : dropDownStyle.background + radiusTopLeft : row.children[0].down && !row.children[0].below ? 0 : Style.dialog.radiusButton + radiusBottomLeft : row.children[0].down && row.children[0].below ? 0 : Style.dialog.radiusButton + radiusTopRight : row.children[row.children.length-1].down && !row.children[row.children.length-1].below ? 0 : Style.dialog.radiusButton + radiusBottomRight : row.children[row.children.length-1].down && row.children[row.children.length-1].below ? 0 : Style.dialog.radiusButton + } + + TextMetrics { + id: textMetrics + text: root.metricsLabel+"M" + font: textlabel.font + } + + Text { + id: textlabel + anchors { + left : root.left + verticalCenter : root.verticalCenter + } + font { + pointSize: Style.dialog.fontSize * Style.pt + bold: dropDownStyle.labelBold + } + color: dropDownStyle.text + width: textMetrics.width + verticalAlignment: Text.AlignVCenter + } + + Row { + id: row + + anchors { + left : root.label=="" ? root.left : textlabel.right + bottom : root.bottom + } + padding : Style.dialog.borderInput + + DateBox { + id: monthInput + placeholderText: qsTr("Month") + enabled: !allDates + model: gui.allMonths + onActivated: updateRange() + anchors.verticalCenter: parent.verticalCenter + dropDownStyle: root.dropDownStyle + } + + Rectangle { + width: Style.dialog.borderInput + height: monthInput.height + color: dropDownStyle.line + anchors.verticalCenter: parent.verticalCenter + } + + DateBox { + id: dayInput + placeholderText: qsTr("Day") + enabled: !allDates + model: gui.allDays + onActivated: updateRange() + anchors.verticalCenter: parent.verticalCenter + dropDownStyle: root.dropDownStyle + } + + Rectangle { + width: Style.dialog.borderInput + height: monthInput.height + color: dropDownStyle.line + } + + DateBox { + id: yearInput + placeholderText: qsTr("Year") + enabled: !allDates + model: gui.allYears + onActivated: updateRange() + anchors.verticalCenter: parent.verticalCenter + dropDownStyle: root.dropDownStyle + } + } + + + function setDate(d) { + //console.trace() + //console.log( " setDate ", label, d) + if (isNaN(d = parseInt(d))) return + var newUnix = Math.min(maxDate.getTime(), d*1000) // seconds to ms + newUnix = Math.max(minDate.getTime(), newUnix) + root.updateRange(new Date(newUnix)) + //console.log( " set ", currentDate.getTime()) + } + + + function updateRange(curr) { + if (curr === undefined || isNaN(curr.getTime())) curr = root.getCurrentDate() + //console.log( " update", label, curr, curr.getTime()) + //console.trace() + if (isNaN(curr.getTime())) return // shouldn't happen + // full system date range + var firstYear = parseInt(gui.allYears[0]) + var firstDay = parseInt(gui.allDays[0]) + if ( isNaN(firstYear) || isNaN(firstDay) ) return + // get minimal and maximal available year, month, day + // NOTE: The order is important!!! + var minYear = minDate.getFullYear() + var maxYear = maxDate.getFullYear() + var minMonth = (curr.getFullYear() == minYear ? minDate.getMonth() : 0 ) + var maxMonth = (curr.getFullYear() == maxYear ? maxDate.getMonth() : 11 ) + var minDay = ( + curr.getFullYear() == minYear && + curr.getMonth() == minMonth ? + minDate.getDate() : firstDay + ) + var maxDay = ( + curr.getFullYear() == maxYear && + curr.getMonth() == maxMonth ? + maxDate.getDate() : gui.daysInMonth(curr.getFullYear(), curr.getMonth()+1) + ) + + //console.log("update ranges: ", root.label, minYear, maxYear, minMonth+1, maxMonth+1, minDay, maxDay) + //console.log("update indexes: ", root.label, firstYear-minYear, firstYear-maxYear, minMonth, maxMonth, minDay-firstDay, maxDay-firstDay) + + + yearInput.filterItems(firstYear-maxYear, firstYear-minYear) + monthInput.filterItems(minMonth,maxMonth) // getMonth() is index not a month (i.e. Jan==0) + dayInput.filterItems(minDay-1,maxDay-1) + + // keep ordering from model not from filter + yearInput .currentIndex = firstYear - curr.getFullYear() + monthInput .currentIndex = curr.getMonth() // getMonth() is index not a month (i.e. Jan==0) + dayInput .currentIndex = curr.getDate()-firstDay + + /* + console.log( + "update current indexes: ", root.label, + curr.getFullYear() , '->' , yearInput.currentIndex , + gui.allMonths[curr.getMonth()] , '->' , monthInput.currentIndex , + curr.getDate() , '->' , dayInput.currentIndex + ) + */ + + // test if current date changed + if ( + yearInput.currentText == root.currentDate.getFullYear() && + monthInput.currentText == root.currentDate.toLocaleString(gui.locale, "MMM") && + dayInput.currentText == gui.prependZeros(root.currentDate.getDate(),2) + ) { + //console.log(" currentDate NOT changed", label, root.currentDate.toDateString()) + return + } + + root.currentDate = root.getCurrentDate() + // console.log(" currentDate changed", label, root.currentDate.toDateString()) + } + + // get current date from selected + function getCurrentDate() { + if (isNaN(root.currentDate.getTime())) { // wrong current ? + console.log("!WARNING! Wrong current date format", root.currentDate) + root.currentDate = new Date(0) + } + var currentString = "" + var currentUnix = root.currentDate.getTime() + if ( + yearInput.currentText != "" && + yearInput.currentText != yearInput.placeholderText && + monthInput.currentText != "" && + monthInput.currentText != monthInput.placeholderText + ) { + var day = gui.daysInMonth(yearInput.currentText, monthInput.currentText) + if (!isNaN(parseInt(dayInput.currentText))) { + day = Math.min(day, parseInt(dayInput.currentText)) + } + currentString = [ yearInput.currentText, monthInput.currentText, day].join("-") + currentUnix = Date.fromLocaleDateString( locale, currentString, "yyyy-MMM-d").getTime() + } + return new Date(Math.max( + minDate.getTime(), + Math.min(maxDate.getTime(), currentUnix) + )) + } +} + diff --git a/internal/frontend/qml/ImportExportUI/DateRange.qml b/internal/frontend/qml/ImportExportUI/DateRange.qml new file mode 100644 index 00000000..26ebc016 --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/DateRange.qml @@ -0,0 +1,121 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// input for date range +import QtQuick 2.8 +import QtQuick.Controls 2.2 +import ProtonUI 1.0 +import ImportExportUI 1.0 + + +Column { + id: dateRange + + property var structure : structureExternal + property string sourceID : structureExternal.getID ( -1 ) + + property alias allDates : allDatesBox.checked + property alias inputDateFrom : inputDateFrom + property alias inputDateTo : inputDateTo + + function setRange() {common.setRange()} + function applyRange() {common.applyRange()} + + property var dropDownStyle : Style.dropDownLight + property var isDark : dropDownStyle.background == Style.dropDownDark.background + + spacing: Style.dialog.spacing + + DateRangeFunctions {id:common} + + DateInput { + id: inputDateFrom + label: qsTr("From:") + currentDate: gui.netBday + maxDate: inputDateTo.currentDate + dropDownStyle: dateRange.dropDownStyle + } + + Rectangle { + width: inputDateTo.width + height: Style.dialog.borderInput / 2 + color: isDark ? dropDownStyle.separator : Style.transparent + } + + DateInput { + id: inputDateTo + label: qsTr("To:") + metricsLabel: inputDateFrom.label + currentDate: new Date() // now + minDate: inputDateFrom.currentDate + dropDownStyle: dateRange.dropDownStyle + } + + Rectangle { + width: inputDateTo.width + height: Style.dialog.borderInput + color: isDark ? dropDownStyle.separator : Style.transparent + } + + CheckBoxLabel { + id: allDatesBox + text : qsTr("All dates") + anchors.right : inputDateTo.right + checkedSymbol : Style.fa.toggle_on + uncheckedSymbol : Style.fa.toggle_off + uncheckedColor : Style.main.textDisabled + textColor : dropDownStyle.text + symbolPointSize : Style.dialog.iconSize * Style.pt * 1.1 + spacing : Style.dialog.spacing*2 + + TextMetrics { + id: metrics + text: allDatesBox.checkedSymbol + font { + family: Style.fontawesome.name + pointSize: allDatesBox.symbolPointSize + } + } + + Rectangle { + color: allDatesBox.checked ? dotBackground.color : Style.exporting.sliderBackground + width: metrics.width*0.9 + height: metrics.height*0.6 + radius: height/2 + z: -1 + + anchors { + left: allDatesBox.left + verticalCenter: allDatesBox.verticalCenter + leftMargin: 0.05 * metrics.width + } + + Rectangle { + id: dotBackground + color : Style.exporting.background + height : parent.height + width : height + radius : height/2 + anchors { + left : parent.left + verticalCenter : parent.verticalCenter + } + + } + } + } +} diff --git a/internal/frontend/qml/ImportExportUI/DateRangeFunctions.qml b/internal/frontend/qml/ImportExportUI/DateRangeFunctions.qml new file mode 100644 index 00000000..8dceb497 --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/DateRangeFunctions.qml @@ -0,0 +1,81 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// input for date range +import QtQuick 2.8 +import QtQuick.Controls 2.2 +import ProtonUI 1.0 +import ImportExportUI 1.0 + +Item { + id: root + /* + NOTE: need to be in obejct with + id: dateRange + + property var structure : structureExternal + property string sourceID : structureExternal.getID ( -1 ) + + property alias allDates : allDatesBox.checked + property alias inputDateFrom : inputDateFrom + property alias inputDateTo : inputDateTo + + function setRange() {common.setRange()} + function applyRange() {common.applyRange()} + */ + + function resetRange() { + inputDateFrom.setDate(gui.netBday.getTime()) + inputDateTo.setDate((new Date()).getTime()) + } + + function setRange(){ // unix time in seconds + var folderFrom = dateRange.structure.getFrom(dateRange.sourceID) + if (folderFrom===undefined) folderFrom = 0 + var folderTo = dateRange.structure.getTo(dateRange.sourceID) + if (folderTo===undefined) folderTo = 0 + if ( folderFrom == 0 && folderTo ==0 ) { + dateRange.allDates = true + } else { + dateRange.allDates = false + inputDateFrom.setDate(folderFrom) + inputDateTo.setDate(folderTo) + } + } + + function applyRange(){ // unix time is seconds + if (dateRange.allDates) structure.setFromToDate(dateRange.sourceID, 0, 0) + else { + var endOfDay = new Date(inputDateTo.unix*1000) + endOfDay.setHours(23,59,59,999) + var endOfDayUnix = parseInt(endOfDay.getTime()/1000) + structure.setFromToDate(dateRange.sourceID, inputDateFrom.unix, endOfDayUnix) + } + } + + Connections { + target: dateRange + onStructureChanged: setRange() + } + + Component.onCompleted: { + inputDateFrom.updateRange(gui.netBday) + inputDateTo.updateRange(new Date()) + setRange() + } +} + diff --git a/internal/frontend/qml/ImportExportUI/DateRangeMenu.qml b/internal/frontend/qml/ImportExportUI/DateRangeMenu.qml new file mode 100644 index 00000000..4ef6853b --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/DateRangeMenu.qml @@ -0,0 +1,151 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// List of import folder and their target +import QtQuick 2.8 +import QtQuick.Controls 2.2 +import ProtonUI 1.0 +import ImportExportUI 1.0 + + +Rectangle { + id:root + + width : icon.width + indicator.width + 3*padding + height : icon.height + 3*padding + + property real padding : Style.dialog.spacing + property bool down : popup.visible + + property var structure : structureExternal + property string sourceID : structureExternal.getID(-1) + + color: Style.transparent + + RoundedRectangle { + anchors.fill: parent + radiusTopLeft: root.down ? 0 : Style.dialog.radiusButton + fillColor: root.down ? Style.main.textBlue : Style.transparent + } + + Text { + id: icon + text: Style.fa.calendar_o + anchors { + left : parent.left + leftMargin : root.padding + verticalCenter : parent.verticalCenter + } + + color: root.enabled ? ( + root.down ? Style.main.background : Style.main.text + ) : Style.main.textDisabled + + font.family : Style.fontawesome.name + + Text { + anchors { + verticalCenter: parent.bottom + horizontalCenter: parent.right + } + + color : !root.down && root.enabled ? Style.main.textRed : icon.color + text : Style.fa.exclamation_circle + visible : !dateRangeInput.allDates + font.pointSize : root.padding * Style.pt * 1.5 + font.family : Style.fontawesome.name + } + } + + + Text { + id: indicator + anchors { + right : parent.right + rightMargin : root.padding + verticalCenter : parent.verticalCenter + } + + text : root.down ? Style.fa.chevron_up : Style.fa.chevron_down + color : !root.down && root.enabled ? Style.main.textBlue : icon.color + font.family : Style.fontawesome.name + } + + MouseArea { + anchors.fill: root + onClicked: { + popup.open() + } + } + + Popup { + id: popup + + x : -width + modal : true + clip : true + + topPadding : 0 + + background: RoundedRectangle { + fillColor : Style.bubble.paneBackground + strokeColor : fillColor + radiusTopRight: 0 + + RoundedRectangle { + anchors { + left: parent.left + right: parent.right + top: parent.top + } + height: Style.dialog.heightInput + fillColor: Style.dropDownDark.highlight + strokeColor: fillColor + radiusTopRight: 0 + radiusBottomLeft: 0 + radiusBottomRight: 0 + } + } + + contentItem : Column { + spacing: Style.dialog.spacing + + Text { + anchors { + left: parent.left + } + + text : qsTr("Import date range") + font.bold : Style.dropDownDark.labelBold + color : Style.dropDownDark.text + height : Style.dialog.heightInput + verticalAlignment : Text.AlignVCenter + } + + DateRange { + id: dateRangeInput + allDates: true + structure: root.structure + sourceID: root.sourceID + dropDownStyle: Style.dropDownDark + } + } + + onAboutToShow : dateRangeInput.setRange() + onAboutToHide : dateRangeInput.applyRange() + } +} diff --git a/internal/frontend/qml/ImportExportUI/DialogExport.qml b/internal/frontend/qml/ImportExportUI/DialogExport.qml new file mode 100644 index 00000000..2d44fb74 --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/DialogExport.qml @@ -0,0 +1,457 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Export dialog +import QtQuick 2.8 +import QtQuick.Controls 2.2 +import ProtonUI 1.0 +import ImportExportUI 1.0 + +// TODO +// - make ErrorDialog module +// - map decision to error code : ask (default), skip () +// - what happens when import fails ? heuristic to find mail where to start from + +Dialog { + id: root + + enum Page { + LoadingStructure = 0, Options, Progress + } + + title : set_title() + + property string address + property alias finish: finish + + property string msgClearUnfished: qsTr ("Remove already exported files.") + + isDialogBusy : true // currentIndex == 0 || currentIndex == 3 + + signal cancel() + signal okay() + + + Rectangle { // 0 + id: dialogLoading + width: root.width + height: root.height + color: Style.transparent + + Text { + anchors.centerIn : dialogLoading + font.pointSize: Style.dialog.titleSize * Style.pt + color: Style.dialog.text + horizontalAlignment: Text.AlignHCenter + text: qsTr("Loading folders and labels for", "todo") +"\n" + address + } + } + + Rectangle { // 1 + id: dialogInput + width: root.width + height: root.height + color: Style.transparent + + Row { + id: inputRow + anchors { + topMargin : root.titleHeight + top : parent.top + horizontalCenter : parent.horizontalCenter + } + spacing: 3*Style.main.leftMargin + property real columnWidth : (root.width - Style.main.leftMargin - inputRow.spacing - Style.main.rightMargin) / 2 + property real columnHeight : root.height - root.titleHeight - Style.main.leftMargin + + + ExportStructure { + id: sourceFoldersInput + width : inputRow.columnWidth + height : inputRow.columnHeight + title : qsTr("From: %1", "todo").arg(address) + } + + Column { + spacing: (inputRow.columnHeight - dateRangeInput.height - outputFormatInput.height - outputPathInput.height - buttonRow.height - infotipEncrypted.height) / 4 + + DateRange{ + id: dateRangeInput + structure: structurePM + sourceID: structurePM.getID(-1) + } + + OutputFormat { + id: outputFormatInput + } + + Row { + spacing: Style.dialog.spacing + CheckBoxLabel { + id: exportEncrypted + text: qsTr("Export emails that cannot be decrypted as ciphertext") + anchors { + bottom: parent.bottom + bottomMargin: Style.dialog.fontSize/1.8 + } + } + + InfoToolTip { + id: infotipEncrypted + anchors { + verticalCenter: exportEncrypted.verticalCenter + } + info: qsTr("Checking this option will export all emails that cannot be decrypted in ciphertext. If this option is not checked, these emails will not be exported", "todo") + } + } + + FileAndFolderSelect { + id: outputPathInput + title: qsTr("Select location of export:", "todo") + width : inputRow.columnWidth // stretch folder input + } + + Row { + id: buttonRow + anchors.right : parent.right + spacing : Style.dialog.rightMargin + + ButtonRounded { + id:buttonCancel + fa_icon: Style.fa.times + text: qsTr("Cancel") + color_main: Style.main.textBlue + onClicked : root.cancel() + } + + ButtonRounded { + id: buttonNext + fa_icon: Style.fa.check + text: qsTr("Export","todo") + enabled: structurePM != 0 + color_main: Style.dialog.background + color_minor: enabled ? Style.dialog.textBlue : Style.main.textDisabled + isOpaque: true + onClicked : root.okay() + } + } + } + } + } + + Rectangle { // 2 + id: progressStatus + width: root.width + height: root.height + color: "transparent" + + Row { + anchors { + bottom: progressbarExport.top + bottomMargin: Style.dialog.heightSeparator + left: progressbarExport.left + } + spacing: Style.main.rightMargin + AccessibleText { + id: statusLabel + text : qsTr("Exporting to:") + font.pointSize: Style.main.iconSize * Style.pt + color : Style.main.text + } + AccessibleText { + anchors.baseline: statusLabel.baseline + text : go.progressDescription == gui.enums.progressInit ? outputPathInput.path : go.progressDescription + elide: Text.ElideMiddle + width: progressbarExport.width - parent.spacing - statusLabel.width + font.pointSize: Style.dialog.textSize * Style.pt + color : Style.main.textDisabled + } + } + + ProgressBar { + id: progressbarExport + implicitWidth : 2*progressStatus.width/3 + implicitHeight : Style.exporting.rowHeight + value: go.progress + property int current: go.total * go.progress + property bool isFinished: finishedPartBar.width == progressbarExport.width + anchors { + centerIn: parent + } + background: Rectangle { + radius : Style.exporting.boxRadius + color : Style.exporting.progressBackground + } + contentItem: Item { + Rectangle { + id: finishedPartBar + width : parent.width * progressbarExport.visualPosition + height : parent.height + radius : Style.exporting.boxRadius + gradient : Gradient { + GradientStop { position: 0.00; color: Qt.lighter(Style.exporting.progressStatus,1.1) } + GradientStop { position: 0.66; color: Style.exporting.progressStatus } + GradientStop { position: 1.00; color: Qt.darker(Style.exporting.progressStatus,1.1) } + } + + Behavior on width { + NumberAnimation { duration:800; easing.type: Easing.InOutQuad } + } + } + Text { + anchors.centerIn: parent + text: { + if (progressbarExport.isFinished) return qsTr("Export finished","todo") + if ( + go.progressDescription == gui.enums.progressInit || + (go.progress==0 && go.description=="") + ) { + if (go.total>1) return qsTr("Estimating the total number of messages (%1)","todo").arg(go.total) + else return qsTr("Estimating the total number of messages","todo") + } + var msg = qsTr("Exporting message %1 of %2 (%3%)","todo") + if (pauseButton.paused) msg = qsTr("Exporting paused at message %1 of %2 (%3%)","todo") + return msg.arg(progressbarExport.current).arg(go.total).arg(Math.floor(go.progress*100)) + } + color: Style.main.background + font { + pointSize: Style.dialog.fontSize * Style.pt + } + } + } + } + + Row { + anchors { + top: progressbarExport.bottom + topMargin : Style.dialog.heightSeparator + horizontalCenter: parent.horizontalCenter + } + spacing: Style.dialog.rightMargin + + ButtonRounded { + id: pauseButton + property bool paused : false + fa_icon : paused ? Style.fa.play : Style.fa.pause + text : paused ? qsTr("Resume") : qsTr("Pause") + color_main : Style.dialog.textBlue + onClicked : { + if (paused) { + if (winMain.updateState == gui.enums.statusNoInternet) { + go.notifyError(gui.enums.errNoInternet) + return + } + go.resumeProcess() + } else { + go.pauseProcess() + } + paused = !paused + pauseButton.focus=false + } + visible : !progressbarExport.isFinished + } + + ButtonRounded { + fa_icon : Style.fa.times + text : qsTr("Cancel") + color_main : Style.dialog.textBlue + visible : !progressbarExport.isFinished + onClicked : root.ask_cancel_progress() + } + + ButtonRounded { + id: finish + fa_icon : Style.fa.check + text : qsTr("Okay","todo") + color_main : Style.dialog.background + color_minor : Style.dialog.textBlue + isOpaque : true + visible : progressbarExport.isFinished + onClicked : root.okay() + } + } + + ClickIconText { + id: buttonHelp + anchors { + right : parent.right + bottom : parent.bottom + rightMargin : Style.main.rightMargin + bottomMargin : Style.main.rightMargin + } + textColor : Style.main.textDisabled + iconText : Style.fa.question_circle + text : qsTr("Help", "directs the user to the online user guide") + textBold : true + onClicked : Qt.openUrlExternally("https://protonmail.com/support/categories/import-export/") + } + } + + PopupMessage { + id: errorPopup + width: root.width + height: root.height + } + + function check_inputs() { + if (currentIndex == 1) { + // at least one email to export + if (structurePM.rowCount() == 0){ + errorPopup.show(qsTr("No emails found to export. Please try another address.", "todo")) + return false + } + // at least one source selected + if (!structurePM.atLeastOneSelected) { + errorPopup.show(qsTr("Please select at least one item to export.", "todo")) + return false + } + // check path + var folderCheck = go.checkPathStatus(outputPathInput.path) + switch (folderCheck) { + case gui.enums.pathEmptyPath: + errorPopup.show(qsTr("Missing export path. Please select an output folder.")) + break; + case gui.enums.pathWrongPath: + errorPopup.show(qsTr("Folder '%1' not found. Please select an output folder.").arg(outputPathInput.path)) + break; + case gui.enums.pathOK | gui.enums.pathNotADir: + errorPopup.show(qsTr("File '%1' is not a folder. Please select an output folder.").arg(outputPathInput.path)) + break; + case gui.enums.pathWrongPermissions: + errorPopup.show(qsTr("Cannot access folder '%1'. Please check folder permissions.").arg(outputPathInput.path)) + break; + } + if ( + (folderCheck&gui.enums.pathOK)==0 || + (folderCheck&gui.enums.pathNotADir)==gui.enums.pathNotADir + ) return false + if (winMain.updateState == gui.enums.statusNoInternet) { + errorPopup.show(qsTr("Please check your internet connection.")) + return false + } + } + return true + } + + function set_title() { + switch(root.currentIndex){ + case 1 : return qsTr("Select what you'd like to export:") + default: return "" + } + } + + function clear_status() { + go.progress=0.0 + go.total=0.0 + go.progressDescription=gui.enums.progressInit + } + + function ask_cancel_progress(){ + errorPopup.buttonYes.visible = true + errorPopup.buttonNo.visible = true + errorPopup.buttonOkay.visible = false + errorPopup.checkbox.text = root.msgClearUnfished + errorPopup.show ("Are you sure you want to cancel this export?") + } + + + onCancel : { + switch (root.currentIndex) { + case 0 : + case 1 : root.hide(); break; + case 2 : // progress bar + go.cancelProcess ( + errorPopup.checkbox.text == root.msgClearUnfished && + errorPopup.checkbox.checked + ); + // no break + default: + root.clear_status() + root.currentIndex=1 + } + } + + onOkay : { + var isOK = check_inputs() + if (!isOK) return + timer.interval= currentIndex==1 ? 1 : 300 + switch (root.currentIndex) { + case 2: // progress + root.clear_status() + root.hide() + break + case 0: // loading structure + dateRangeInput.setRange() + //no break + default: + incrementCurrentIndex() + timer.start() + } + } + + onShow: { + if (winMain.updateState==gui.enums.statusNoInternet) { + go.checkInternet() + if (winMain.updateState==gui.enums.statusNoInternet) { + go.notifyError(gui.enums.errNoInternet) + root.hide() + return + } + } + + root.clear_status() + root.currentIndex=0 + timer.interval = 300 + timer.start() + dateRangeInput.allDates = true + } + + Connections { + target: timer + onTriggered : { + switch (currentIndex) { + case 0: + go.loadStructureForExport(root.address) + sourceFoldersInput.hasItems = (structurePM.rowCount() > 0) + break + case 2: + dateRangeInput.applyRange() + go.startExport( + outputPathInput.path, + root.address, + outputFormatInput.checkedText, + exportEncrypted.checked + ) + break + } + } + } + + Connections { + target: errorPopup + + onClickedOkay : errorPopup.hide() + onClickedYes : { + root.cancel() + errorPopup.hide() + } + onClickedNo : { + go.resumeProcess() + errorPopup.hide() + } + } +} diff --git a/internal/frontend/qml/ImportExportUI/DialogImport.qml b/internal/frontend/qml/ImportExportUI/DialogImport.qml new file mode 100644 index 00000000..380e3fd4 --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/DialogImport.qml @@ -0,0 +1,1027 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Export dialog +import QtQuick 2.8 +import QtQuick.Controls 2.2 +import QtQuick.Layouts 1.3 +import QtQuick.Dialogs 1.0 +import ProtonUI 1.0 +import ImportExportUI 1.0 + +Dialog { + id: root + + enum Page { + SelectSourceType=0, ImapSource, LoadingStructure, SourceToTarget, Progress, Report + } + + title: "" // qsTr("Importing from: %1", "todo").arg(address) + + isDialogBusy: currentIndex==3 || currentIndex==4 + + property string address + property string inputPath : "" + property bool isFromFile : inputEmail.text == "" && root.inputPath != "" + property bool isFromIMAP : inputEmail.text != "" + property bool paused : false + + property string msgDontShowAgain : qsTr("Do not show this message again") + + + signal cancel() + signal okay() + + + Rectangle { // SelectSourceType + id: sourceType + width: parent.width + height: parent.height + color: "transparent" + + Text { + anchors { + horizontalCenter : parent.horizontalCenter + top : parent.top + topMargin : Style.dialog.titleSize + } + + font.pointSize: Style.dialog.titleSize * Style.pt + color: Style.dialog.text + text: qsTr("Please select the source of the emails that you would like to import:") + } + + Row { + anchors { + centerIn: parent + } + + ImportSourceButton { + id: imapButton + width: winMain.width/2 + iconText: "envelope_open" + text: qsTr("Import from account") + onClicked: root.incrementCurrentIndex() + } + + ImportSourceButton { + id: fileButton + width: winMain.width/2 + iconText: "folder_open" + text: qsTr("Import local files") + onClicked: { pathDialog.visible = true } + anchors.bottom: imapButton.bottom + } + } + + FileDialog { + id: pathDialog + title: "Select local folder to import" + folder: shortcuts.home + onAccepted: { + sanitizePath(pathDialog.fileUrl.toString()) + root.okay() + } + selectFolder: true + } + } + + Rectangle { // ImapSource + id: imapSource + + Text { + id: imapSourceTitle + anchors { + top : parent.top + topMargin: imapSourceTitle.height / 2 + horizontalCenter : parent.horizontalCenter + } + + font.pointSize: Style.dialog.titleSize * Style.pt + color: Style.dialog.text + text: qsTr("Sign in to your Account") + } + + Rectangle { // line + id: titleLine + anchors { + top: imapSourceTitle.bottom + topMargin: imapSourceTitle.height / 2 + horizontalCenter : parent.horizontalCenter + } + width: imapSourceContent.width + height: Style.main.heightLine + color: Style.main.line + } + + Rectangle { + id: imapSourceContent + anchors { + top: titleLine.bottom + topMargin: imapSourceTitle.height / 2 + bottom: buttonRow.top + } + width: winMain.width + color: Style.dialog.background + + Text { + id: note + anchors { + bottom: wrapper.top + bottomMargin: imapSourceTitle.height / 2 + horizontalCenter : parent.horizontalCenter + } + text: qsTr( + "Many email providers (Gmail, Yahoo, etc.) will require you to allow remote sign-on in order to perform import through IMAP. See this article for details about how to do this with your email account.", + "Note added at IMAP credential page." + ).arg("https://protonmail.com/support/knowledge-base/allowing-imap-access-and-entering-imap-details/") + font { + pointSize: Style.dialog.fontSize * Style.pt + } + color: Style.dialog.text + linkColor: Style.dialog.textBlue + wrapMode: Text.WordWrap + textFormat: Text.StyledText + horizontalAlignment: Text.AlignHCenter + + width: parent.width * 0.618 + onLinkActivated: Qt.openUrlExternally(link) + } + + Rectangle { + id: wrapper + anchors.centerIn: parent + width: firstRow.width + height: firstRow.height + secondRow.height + secondRow.anchors.topMargin + color: Style.transparent + Row { + id: firstRow + spacing: imapSourceTitle.height + + InputField { + id: inputEmail + iconText: Style.fa.user_circle + label: qsTr("Email", "todo") + ":" + onEditingFinished: { + root.guessEmailProvider() + } + onAccepted: if (root.check_inputs()) root.okay() + anchors.horizontalCenter: undefined + } + + InputField { + id: inputPassword + label : qsTr("Password:") + iconText : Style.fa.lock + isPassword: true + onAccepted: if (root.check_inputs()) root.okay() + anchors.horizontalCenter: undefined + } + } + + Row { + id: secondRow + spacing: imapSourceTitle.height + anchors { + top: firstRow.bottom + topMargin: 2*imapSourceTitle.height + } + + InputField { + id: inputServer + iconText: Style.fa.server + label: qsTr("Server address", "todo") + ":" + onAccepted: if (root.check_inputs()) root.okay() + anchors.horizontalCenter: undefined + } + + InputField { + id: inputPort + iconText: Style.fa.hashtag + label: qsTr("Port:") + onAccepted: if (root.check_inputs()) root.okay() + anchors.horizontalCenter: undefined + } + } + } + } + + Row { // Buttons + id:buttonRow + anchors { + right : parent.right + bottom : parent.bottom + rightMargin : Style.dialog.rightMargin + bottomMargin : Style.dialog.bottomMargin + } + spacing: Style.main.leftMargin + + ButtonRounded { + fa_icon : Style.fa.times + text : qsTr("Cancel", "todo") + color_main : Style.dialog.textBlue + onClicked : root.cancel() + } + + ButtonRounded { + fa_icon : Style.fa.check + text : qsTr("Next", "todo") + color_main : Style.dialog.background + color_minor : Style.dialog.textBlue + isOpaque : true + onClicked : root.okay() + } + } + } + + Rectangle { // LoadingStructure + id: loadingStructures + color : Style.dialog.background + width : parent.width + height : parent.height + + Text { + anchors { + verticalCenter : parent.verticalCenter + horizontalCenter : parent.horizontalCenter + topMargin : Style.dialog.titleSize + } + font.pointSize: Style.dialog.titleSize * Style.pt + color: Style.dialog.text + text: root.isFromFile ? qsTr("Loading folder structures, please wait...") : qsTr("Loading structure of IMAP account, please wait...") + } + } + + Rectangle { // SourceToTarget + id: dialogStructure + width : parent.width + height : parent.height + + // Import instructions + ImportStructure { + id: importInstructions + anchors.bottom : masterImportSettings.top + titleFrom : root.isFromFile ? root.inputPath : inputEmail.text + titleTo : root.address + } + + Rectangle { + id: masterImportSettings + height: 150 // fixme + anchors { + right : parent.right + left : parent.left + bottom : parent.bottom + + leftMargin : Style.main.leftMargin + rightMargin : Style.main.leftMargin + bottomMargin : Style.main.bottomMargin + } + color: Style.dialog.background + + Text { + id: labelMasterImportSettings + text: qsTr("Master import settings:") + + font { + bold: true + family: Style.fontawesome.name + pointSize: Style.main.fontSize * Style.pt + } + color: Style.main.text + + InfoToolTip { + info: qsTr( + "If master import date range is selected only emails within this range will be imported, unless it is specified differently in folder date range.", + "Text in master import settings tooltip." + ) + anchors { + left: parent.right + bottom: parent.bottom + leftMargin : Style.dialog.leftMargin + } + } + } + + // Reset all to default + ClickIconText { + anchors { + right: parent.right + bottom: labelMasterImportSettings.bottom + } + text:qsTr("Reset all settings to default") + iconText: Style.fa.refresh + textColor: Style.main.textBlue + onClicked: { + root.decrementCurrentIndex() + timer.start() + } + } + + Rectangle{ + id: line + anchors { + left : parent.left + right : parent.right + top : labelMasterImportSettings.bottom + + topMargin : Style.dialog.spacing + } + height : Style.main.border * 2 + color : Style.main.line + } + + InlineDateRange { + id: globalDateRange + anchors { + left : parent.left + top : line.bottom + topMargin : Style.dialog.topMargin + } + } + + // Add global label (inline) + InlineLabelSelect { + id: globalLabels + anchors { + left : parent.left + top : globalDateRange.bottom + topMargin : Style.dialog.topMargin + } + //labelWidth : globalDateRange.labelWidth + } + + // Buttons + Row { + spacing: Style.dialog.spacing + anchors{ + bottom : parent.bottom + right : parent.right + } + + ButtonRounded { + id: buttonCancelThree + fa_icon : Style.fa.times + text : qsTr("Cancel", "todo") + color_main : Style.dialog.textBlue + onClicked : root.cancel() + } + + ButtonRounded { + id: buttonNextThree + fa_icon : Style.fa.check + text : qsTr("Import", "todo") + color_main : Style.dialog.background + color_minor : Style.dialog.textBlue + isOpaque : true + onClicked : root.okay() + } + } + } + } + + Rectangle { // Progress + id: progressStatus + width : parent.width + height : parent.height + color: Style.transparent + + Column { + anchors.centerIn: progressStatus + spacing: Style.dialog.heightSeparator + + Row { // description + spacing: Style.main.rightMargin + AccessibleText { + id: statusLabel + text : qsTr("Importing from:") + font.pointSize: Style.main.iconSize * Style.pt + color : Style.main.text + } + AccessibleText { + anchors.baseline: statusLabel.baseline + text : { + var sourceFolder = root.isFromFile ? root.inputPath : inputEmail.text + if (go.progressDescription != gui.enums.progressInit && go.progress!=0) { + sourceFolder += "/" + sourceFolder += go.progressDescription + } + return sourceFolder + } + elide: Text.ElideMiddle + width: progressbarImport.width - parent.spacing - statusLabel.width + font.pointSize: Style.dialog.textSize * Style.pt + color : Style.main.textDisabled + } + } + + ProgressBar { + id: progressbarImport + implicitWidth : 2*progressStatus.width/3 + implicitHeight : Style.exporting.rowHeight + value: go.progress + property int current: go.total * go.progress + property bool isFinished: finishedPartBar.width == progressbarImport.width + + background: Rectangle { + radius : Style.exporting.boxRadius + color : Style.exporting.progressBackground + } + + contentItem: Item { + Rectangle { + id: finishedPartBar + width : parent.width * progressbarImport.visualPosition + height : parent.height + radius : Style.exporting.boxRadius + gradient : Gradient { + GradientStop { position: 0.00; color: Qt.lighter(Style.exporting.progressStatus,1.1) } + GradientStop { position: 0.66; color: Style.exporting.progressStatus } + GradientStop { position: 1.00; color: Qt.darker(Style.exporting.progressStatus,1.1) } + } + + Behavior on width { + NumberAnimation { duration:800; easing.type: Easing.InOutQuad } + } + } + Text { + anchors.centerIn: parent + text: { + if (progressbarImport.isFinished) return qsTr("Import finished","todo") + if ( + go.progressDescription == gui.enums.progressInit || + (go.progress == 0 && go.description=="") + ) return qsTr("Estimating the total number of messages","todo") + if ( + go.progressDescription == gui.enums.progressLooping + ) return qsTr("Loading first message","todo") + //var msg = qsTr("Importing message %1 of %2 (%3%)","todo") + var msg = qsTr("Importing messages %1 of %2 (%3%)","todo") + if (root.paused) msg = qsTr("Importing paused at %1 of %2 (%3%)","todo") + return msg.arg(progressbarImport.current).arg(go.total).arg(Math.floor(go.progress*100)) + } + color: Style.main.background + font { + pointSize: Style.dialog.fontSize * Style.pt + } + } + } + + onIsFinishedChanged: { // show report + console.log("Is finished ", progressbarImport.isFinished) + if (progressbarImport.isFinished && root.currentIndex == DialogImport.Page.Progress) { + root.incrementCurrentIndex() + } + } + } + + Text { + property int fails: go.progressFails + visible: fails > 0 + color : Style.main.textRed + font.family: Style.fontawesome.name + font.pointSize: Style.main.fontSize * Style.pt + anchors.horizontalCenter: parent.horizontalCenter + text: Style.fa.exclamation_circle + " " + ( + fails == 1 ? + qsTr("%1 message failed to be imported").arg(fails) : + qsTr("%1 messages failed to be imported").arg(fails) + ) + } + + Row { // buttons + spacing: Style.dialog.rightMargin + anchors.horizontalCenter: parent.horizontalCenter + + ButtonRounded { + id: pauseButton + fa_icon : root.paused ? Style.fa.play : Style.fa.pause + text : root.paused ? qsTr("Resume") : qsTr("Pause") + color_main : Style.dialog.textBlue + onClicked : { + if (root.paused) { + if (winMain.updateState == gui.enums.statusNoInternet) { + go.notifyError(gui.enums.errNoInternet) + return + } + go.resumeProcess() + } else { + go.pauseProcess() + } + root.paused = !root.paused + pauseButton.focus=false + } + visible : !progressbarImport.isFinished + } + + ButtonRounded { + fa_icon : Style.fa.times + text : qsTr("Cancel") + color_main : Style.dialog.textBlue + visible : !progressbarImport.isFinished + onClicked : root.ask_cancel_progress() + } + + ButtonRounded { + id: finish + fa_icon : Style.fa.check + text : qsTr("Okay","todo") + color_main : Style.dialog.background + color_minor : Style.dialog.textBlue + isOpaque : true + visible : progressbarImport.isFinished + onClicked : root.okay() + } + } + } + + ImportReport { + } + + ClickIconText { + id: buttonHelp + anchors { + bottom: progressStatus.bottom + right: progressStatus.right + margins: Style.main.rightMargin + } + + textColor : Style.main.textDisabled + iconText : Style.fa.question_circle + text : qsTr("Help", "directs the user to the online user guide") + textBold : true + onClicked : Qt.openUrlExternally("https://protonmail.com/support/categories/import-export/") + } + } + + Rectangle { // Report + id: finalReport + width : parent.width + height : parent.height + color: Style.transparent + + property int imported: go.total - go.progressFails + + + Column { + anchors.centerIn : finalReport + spacing : Style.dialog.heightSeparator + + Text { + text: Style.fa.check_circle + " " + qsTr("Import completed successfully") + anchors.horizontalCenter: parent.horizontalCenter + color: Style.main.textGreen + font.bold : true + font.family: Style.fontawesome.name + } + + Text { + text: qsTr("Import summary:
Total number of emails: %1
Imported emails: %2
Errors: %3").arg(go.total).arg(finalReport.imported).arg(go.progressFails) + anchors.horizontalCenter: parent.horizontalCenter + textFormat: Text.RichText + horizontalAlignment: Text.AlignHCenter + } + + Row { + spacing: Style.dialog.rightMargin + anchors.horizontalCenter: parent.horizontalCenter + + ButtonRounded { + fa_icon : Style.fa.info_circle + text : qsTr("View errors") + color_main : Style.dialog.textBlue + onClicked : { + if (go.importLogFileName=="") { + console.log("onViewErrors: missing import log file name") + return + } + go.loadImportReports(go.importLogFileName) + reportList.show() + } + } + + ButtonRounded { + fa_icon : Style.fa.send + text : qsTr("Report files") + color_main : Style.dialog.textBlue + onClicked : { + if (go.importLogFileName=="") { + console.log("onReportError: missing import log file name") + return + } + root.ask_send_report() + } + } + } + + ButtonRounded { + text : qsTr("Close") + color_main : Style.dialog.background + color_minor : Style.dialog.textBlue + isOpaque : true + anchors.horizontalCenter: parent.horizontalCenter + onClicked: root.okay() + } + } + + ImportReport { + id: reportList + anchors.fill: finalReport + } + + ClickIconText { + anchors { + bottom: finalReport.bottom + right: finalReport.right + margins: Style.main.rightMargin + } + + textColor : Style.main.textDisabled + iconText : Style.fa.question_circle + text : qsTr("Help", "directs the user to the online user guide") + textBold : true + onClicked : Qt.openUrlExternally("https://protonmail.com/support/categories/import-export/") + } + } + + function guessEmailProvider() { + var splitMail = inputEmail.text.split("@") + //console.log("finished ", splitMail) + if (splitMail.length != 2) return + switch (splitMail[1]){ + case "yandex.ru": + case "yandex.com": + case "ya.ru": + inputServer.text = "imap.yandex.ru" + inputPort.text = "993" + break + case "outlook.com": + case "hotmail.com": + case "live.com": + case "live.ru": + inputServer.text = "imap-mail.outlook.com" + inputPort.text = "993" + break + case "seznam.cz": + case "email.cz": + case "post.cz": + inputServer.text = "imap.seznam.cz" + inputPort.text = "993" + break + case "gmx.de": + inputServer.text = "imap.gmx.net" + inputPort.text = "993" + break + case "rundbox.com": + inputServer.text = "mail."+splitMail[1] + inputPort.text = "993" + break + case "fastmail.com": + case "aol.com": + case "orange.fr": + case "hushmail.com": + case "ntlworld.com": + case "aol.com": + case "gmx.com": + case "mail.com": + case "mail.ru": + case "gmail.com": + inputServer.text = "imap."+splitMail[1] + inputPort.text = "993" + break + case (splitMail[1].match(/^yahoo\./) || {}).input: + inputServer.text = "imap.mail.yahoo.com" + inputPort.text = "993" + break + default: + } + + return + } + + function setServerParams() { + switch (emailProvider.currentIndex) { + case 1: + inputServer.text = "imap.gmail.com" + inputPort.text = "993" + break + case 2: + inputServer.text = "imap.yandex.com" + inputPort.text = "993" + break + case 3: + inputServer.text = "imap.outlook.com" + inputPort.text = "993" + break + case 4: + inputServer.text = "imap.yahoo.com" + inputPort.text = "993" + break + } + + return + } + + function update_label_time() { + var d = new Date(); + var outstring = " " + outstring+=qsTr("Import") + outstring+="-" + outstring+=d.getDate() + outstring+="-" + outstring+=d.getMonth()+1 + outstring+="-" + outstring+=d.getFullYear() + outstring+=" " + outstring+=d.getHours() + outstring+=":" + outstring+=d.getMinutes() + outstring+=" " + importLabel.text = outstring + } + + function clear() { + go.resetSource() + root.inputPath = "" + clear_status() + inputEmail.clear() + inputPassword.clear() + inputServer.clear() + inputPort.clear() + reportList.hide() + globalLabels.reset() + } + + PopupMessage { + id: errorPopup + width : parent.width + height : parent.height + msgWidth : root.width * 0.6108 + } + + Connections { + target: errorPopup + + onClickedOkay : errorPopup.hide() + + onClickedYes : { + if (errorPopup.msgID == "ask_send_report") { + errorPopup.hide() + root.report_sent(go.sendImportReport(root.address,go.importLogFileName)) + return + } + root.cancel() + errorPopup.hide() + } + onClickedNo : { + if (errorPopup.msgID == "ask_send_report") { + errorPopup.hide() + return + } + go.resumeProcess() + errorPopup.hide() + } + + onClickedRetry : { + go.answerRetry() + errorPopup.hide() + } + onClickedSkip : { + go.answerSkip( + errorPopup.checkbox.text == root.msgDontShowAgain && + errorPopup.checkbox.checked + ) + errorPopup.hide() + } + onClickedCancel : { + root.cancel() + errorPopup.hide() + } + + /* + onClickedCancel : { + errorPopup.hide() + root.ask_cancel_progress() + } + */ + } + + + function check_inputs() { + var isOK = true + switch (currentIndex) { + case 0: // select source + var res = go.checkPathStatus(root.inputPath) + isOK = ( + (res&gui.enums.pathOK)==gui.enums.pathOK && + (res&gui.enums.pathNotADir)==0 && // is a dir + (res&gui.enums.pathDirEmpty)==0 // is nonempty + ) + if (!isOK) errorPopup.show(qsTr("Please select non-empty folder.")) + break + // check directory + case 1: // imap settings + if (!( + inputEmail . checkNonEmpty() && + inputPassword . checkNonEmpty() && + inputServer . checkNonEmpty() && + inputPort . checkNonEmpty() && + inputPort . checkIsANumber() + //emailProvider . currentIndex!=0 + )) isOK = false + go.checkInternet() + if (winMain.updateState == gui.enums.statusNoInternet) { // todo: use main error dialog for this + errorPopup.show(qsTr("Please check your internet connection.")) + return false + } + break + case 2: // loading structure + go.checkInternet() + if (winMain.updateState == gui.enums.statusNoInternet) { + errorPopup.show(qsTr("Please check your internet connection.")) + return false + } + break + case 3: // import insturctions + if (!structureExternal.hasTarget()) { + errorPopup.show(qsTr("Nothing selected for import.")) + return false + } + break + case 4: // import status + } + return isOK + } + + onCancel: { + switch (currentIndex) { + case DialogImport.Page.ImapSource: + case DialogImport.Page.LoadingStructure: + root.clear() + root.currentIndex=0 + break + case DialogImport.Page.SelectSourceType: + case DialogImport.Page.SourceToTarget: + case DialogImport.Page.Report: + root.hide() + break + case DialogImport.Page.Progress: + go.cancelProcess(false) + root.currentIndex=3 + root.clear_status() + globalLabels.reset() + break + } + } + + onOkay: { + var isOK = check_inputs() + if (isOK) { + timer.interval= currentIndex==0 || currentIndex==4 ? 10 : 300 + switch (currentIndex) { + case DialogImport.Page.SelectSourceType: // select source + currentIndex=2 + break + + case DialogImport.Page.SourceToTarget: + globalDateRange.applyRange() + if (globalLabels.labelSelected) { + var isOK = go.createLabelOrFolder( + winMain.dialogImport.address, + globalLabels.labelName, + globalLabels.labelColor, + true, + structureExternal.getID(-1) + ) + if (!isOK) return + } + incrementCurrentIndex() + break + + case DialogImport.Page.Report: + root.clear_status() + root.hide() + break + + case DialogImport.Page.LoadingStructure: + globalLabels.reset() + importInstructions.hasItems = (structureExternal.rowCount() > 0) + case DialogImport.Page.ImapSource: + default: + incrementCurrentIndex() + } + timer.start() + } + } + + onShow : { + root.clear() + if (winMain.updateState==gui.enums.statusNoInternet) { + go.checkInternet() + if (winMain.updateState==gui.enums.statusNoInternet) { + winMain.popupMessage.show(go.canNotReachAPI) + root.hide() + } + } + } + + onHide : { + root.clear() + } + + function clear_status() { // TODO: move this to Gui.qml + go.progress=0.0 + go.progressFails=0.0 + go.total=0.0 + go.progressDescription=gui.enums.progressInit + } + + function ask_send_report(){ + errorPopup.msgID="ask_send_report" + errorPopup.buttonYes.visible = true + errorPopup.buttonNo.visible = true + errorPopup.buttonOkay.visible = false + errorPopup.show (qsTr("Program will send the report of finished import process to our customer support. The report was filtered to remove all personal information.\n\nDo you want to send report?")) + } + + function report_sent(isOK){ + errorPopup.msgID="report_sent" + if (isOK) { + errorPopup.show (qsTr("Report sent successfully.")) + } else { + errorPopup.show (qsTr("Not able to send report. Please contact customer support at importexport@protonmail.com")) + } + } + + function ask_cancel_progress(){ + errorPopup.msgID="ask_cancel_progress" + errorPopup.buttonYes.visible = true + errorPopup.buttonNo.visible = true + errorPopup.buttonOkay.visible = false + errorPopup.show (qsTr("Are you sure you want to cancel this import?")) + } + + function ask_retry_skip_cancel(subject,errorMessage){ + errorPopup.msgID="ask_retry_skip_cancel" + errorPopup.buttonYes.visible = false + errorPopup.buttonNo.visible = false + errorPopup.buttonOkay.visible = false + + errorPopup.buttonRetry.visible = true + errorPopup.buttonSkip.visible = true + errorPopup.buttonCancel.visible = true + + errorPopup.checkbox.text = root.msgDontShowAgain + + errorPopup.show( + qsTr( + "Cannot import message \"%1\"\n\n%2\nCancel will stop the entire import.", + "error message while importing: arg1 is message subject, arg2 is error message" + ).arg(subject).arg(errorMessage) + ) + } + + function sanitizePath(path) { + var pattern = "file://" + if (go.goos=="windows") pattern+="/" + root.inputPath = path.replace(pattern, "") + } + + Connections { + target: timer + onTriggered: { + switch (currentIndex) { + case DialogImport.Page.SelectSourceType: + case DialogImport.Page.ImapSource: + case DialogImport.Page.SourceToTarget: + globalDateRange.setRange() + break + case DialogImport.Page.LoadingStructure: + go.setupAndLoadForImport( + root.isFromIMAP, + root.inputPath, + inputEmail.text, inputPassword.text, inputServer.text, inputPort.text, + root.address + ) + break + case DialogImport.Page.Progress: + go.startImport(root.address) + break + } + } + } +} diff --git a/internal/frontend/qml/ImportExportUI/DialogYesNo.qml b/internal/frontend/qml/ImportExportUI/DialogYesNo.qml new file mode 100644 index 00000000..94d6d0a1 --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/DialogYesNo.qml @@ -0,0 +1,354 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Dialog with Yes/No buttons + +import QtQuick 2.8 +import QtQuick.Controls 2.2 +import ProtonUI 1.0 +import ImportExportUI 1.0 + +Dialog { + id: root + + title : "" + + property string input + + property alias question : msg.text + property alias note : noteText.text + property alias answer : answ.text + property alias buttonYes : buttonYes + property alias buttonNo : buttonNo + + isDialogBusy: currentIndex==1 + + signal confirmed() + + Column { + id: dialogMessage + property int heightInputs : msg.height+ + middleSep.height+ + buttonRow.height + + (checkboxSep.visible ? checkboxSep.height : 0 ) + + (noteSep.visible ? noteSep.height : 0 ) + + (checkBoxWrapper.visible ? checkBoxWrapper.height : 0 ) + + (root.note!="" ? noteText.height : 0 ) + + Rectangle { color : "transparent"; width : Style.main.dummy; height : (root.height-dialogMessage.heightInputs)/2 } + + AccessibleText { + id:noteText + anchors.horizontalCenter: parent.horizontalCenter + color: Style.dialog.text + font { + pointSize: Style.dialog.fontSize * Style.pt + bold: false + } + width: 2*root.width/3 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + } + Rectangle { id: noteSep; visible: note!=""; color : "transparent"; width : Style.main.dummy; height : Style.dialog.heightSeparator} + + AccessibleText { + id: msg + anchors.horizontalCenter: parent.horizontalCenter + color: Style.dialog.text + font { + pointSize: Style.dialog.fontSize * Style.pt + bold: true + } + width: 2*parent.width/3 + text : "" + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + } + + Rectangle { id: checkboxSep; visible: checkBoxWrapper.visible; color : "transparent"; width : Style.main.dummy; height : Style.dialog.heightSeparator} + Row { + id: checkBoxWrapper + property bool isChecked : false + visible: root.state=="deleteUser" + anchors.horizontalCenter: parent.horizontalCenter + spacing: Style.dialog.spacing + + function toggle() { + checkBoxWrapper.isChecked = !checkBoxWrapper.isChecked + } + + Text { + id: checkbox + font { + pointSize : Style.dialog.iconSize * Style.pt + family : Style.fontawesome.name + } + anchors.verticalCenter : parent.verticalCenter + text: checkBoxWrapper.isChecked ? Style.fa.check_square_o : Style.fa.square_o + color: checkBoxWrapper.isChecked ? Style.main.textBlue : Style.main.text + + MouseArea { + anchors.fill: parent + onPressed: checkBoxWrapper.toggle() + cursorShape: Qt.PointingHandCursor + } + } + Text { + id: checkBoxNote + anchors.verticalCenter : parent.verticalCenter + text: qsTr("Additionally delete all stored preferences and data", "when removing an account, this extra preference additionally deletes all cached data") + color: Style.main.text + font.pointSize: Style.dialog.fontSize * Style.pt + + MouseArea { + anchors.fill: parent + onPressed: checkBoxWrapper.toggle() + cursorShape: Qt.PointingHandCursor + + Accessible.role: Accessible.CheckBox + Accessible.checked: checkBoxWrapper.isChecked + Accessible.name: checkBoxNote.text + Accessible.description: checkBoxNote.text + Accessible.ignored: checkBoxNote.text == "" + Accessible.onToggleAction: checkBoxWrapper.toggle() + Accessible.onPressAction: checkBoxWrapper.toggle() + } + } + } + + Rectangle { id: middleSep; color : "transparent"; width : Style.main.dummy; height : 2*Style.dialog.heightSeparator } + + Row { + id: buttonRow + anchors.horizontalCenter: parent.horizontalCenter + spacing: Style.dialog.spacing + ButtonRounded { + id:buttonNo + color_main : Style.dialog.textBlue + fa_icon : Style.fa.times + text : qsTr("No") + onClicked : root.hide() + } + ButtonRounded { + id: buttonYes + color_main : Style.dialog.background + color_minor : Style.dialog.textBlue + isOpaque : true + fa_icon : Style.fa.check + text : qsTr("Yes") + onClicked : { + currentIndex=1 + root.confirmed() + } + } + } + } + + + Column { + Rectangle { color : "transparent"; width : Style.main.dummy; height : (root.height-answ.height)/2 } + AccessibleText { + id: answ + anchors.horizontalCenter: parent.horizontalCenter + color: Style.dialog.text + font { + pointSize : Style.dialog.fontSize * Style.pt + bold : true + } + width: 3*parent.width/4 + horizontalAlignment: Text.AlignHCenter + text : qsTr("Waiting...", "in general this displays between screens when processing data takes a long time") + wrapMode: Text.Wrap + } + } + + + states : [ + State { + name: "quit" + PropertyChanges { + target: root + currentIndex : 0 + title : qsTr("Close ImportExport", "quits the application") + question : qsTr("Are you sure you want to close the ImportExport?", "asked when user tries to quit the application") + note : "" + answer : qsTr("Closing ImportExport...", "displayed when user is quitting application") + } + }, + State { + name: "logout" + PropertyChanges { + target: root + currentIndex : 1 + title : qsTr("Logout", "title of page that displays during account logout") + question : "" + note : "" + answer : qsTr("Logging out...", "displays during account logout") + } + }, + State { + name: "deleteUser" + PropertyChanges { + target: root + currentIndex : 0 + title : qsTr("Delete account", "title of page that displays during account deletion") + question : qsTr("Are you sure you want to remove this account?", "displays during account deletion") + note : "" + answer : qsTr("Deleting ...", "displays during account deletion") + } + }, + State { + name: "clearChain" + PropertyChanges { + target : root + currentIndex : 0 + title : qsTr("Clear keychain", "title of page that displays during keychain clearing") + question : qsTr("Are you sure you want to clear your keychain?", "displays during keychain clearing") + note : qsTr("This will remove all accounts that you have added to the Import-Export tool.", "displays during keychain clearing") + answer : qsTr("Clearing the keychain ...", "displays during keychain clearing") + } + }, + State { + name: "clearCache" + PropertyChanges { + target: root + currentIndex : 0 + title : qsTr("Clear cache", "title of page that displays during cache clearing") + question : qsTr("Are you sure you want to clear your local cache?", "displays during cache clearing") + note : qsTr("This will delete all of your stored preferences.", "displays during cache clearing") + answer : qsTr("Clearing the cache ...", "displays during cache clearing") + } + }, + State { + name: "checkUpdates" + PropertyChanges { + target: root + currentIndex : 1 + title : "" + question : "" + note : "" + answer : qsTr("Checking for updates ...", "displays if user clicks the Check for Updates button in the Help tab") + } + }, + State { + name: "internetCheck" + PropertyChanges { + target: root + currentIndex : 1 + title : "" + question : "" + note : "" + answer : qsTr("Contacting server...", "displays if user clicks the Check for Updates button in the Help tab") + } + }, + State { + name: "addressmode" + PropertyChanges { + target: root + currentIndex : 0 + title : "" + question : qsTr("Do you want to continue?", "asked when the user changes between split and combined address mode") + note : qsTr("Changing between split and combined address mode will require you to delete your account(s) from your email client and begin the setup process from scratch.", "displayed when the user changes between split and combined address mode") + answer : qsTr("Configuring address mode for ", "displayed when the user changes between split and combined address mode") + root.input + } + }, + State { + name: "toggleAutoStart" + PropertyChanges { + target: root + currentIndex : 1 + question : "" + note : "" + title : "" + answer : { + var msgTurnOn = qsTr("Turning on automatic start of ImportExport...", "when the automatic start feature is selected") + var msgTurnOff = qsTr("Turning off automatic start of ImportExport...", "when the automatic start feature is deselected") + return go.isAutoStart==0 ? msgTurnOff : msgTurnOn + } + } + }, + State { + name: "undef"; + PropertyChanges { + target: root + currentIndex : 1 + question : "" + note : "" + title : "" + answer : "" + } + } + ] + + + Shortcut { + sequence: StandardKey.Cancel + onActivated: root.hide() + } + + Shortcut { + sequence: "Enter" + onActivated: root.confirmed() + } + + onHide: { + checkBoxWrapper.isChecked = false + state = "undef" + } + + onShow: { + // hide all other dialogs + winMain.dialogAddUser .visible = false + winMain.dialogCredits .visible = false + //winMain.dialogVersionInfo .visible = false + // dialogFirstStart should reappear again after closing global + root.visible = true + } + + + + onConfirmed : { + if (state == "quit" || state == "instance exists" ) { + timer.interval = 1000 + } else { + timer.interval = 300 + } + answ.forceActiveFocus() + timer.start() + } + + Connections { + target: timer + onTriggered: { + if ( state == "addressmode" ) { go.switchAddressMode (input) } + if ( state == "clearChain" ) { go.clearKeychain () } + if ( state == "clearCache" ) { go.clearCache () } + if ( state == "deleteUser" ) { go.deleteAccount (input, checkBoxWrapper.isChecked) } + if ( state == "logout" ) { go.logoutAccount (input) } + if ( state == "toggleAutoStart" ) { go.toggleAutoStart () } + if ( state == "quit" ) { Qt.quit () } + if ( state == "instance exists" ) { Qt.quit () } + if ( state == "checkUpdates" ) { go.runCheckVersion (true) } + } + } + + Keys.onPressed: { + if (event.key == Qt.Key_Enter) { + root.confirmed() + } + } +} diff --git a/internal/frontend/qml/ImportExportUI/ExportStructure.qml b/internal/frontend/qml/ImportExportUI/ExportStructure.qml new file mode 100644 index 00000000..867f3ab5 --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/ExportStructure.qml @@ -0,0 +1,151 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// List of export folders / labels +import QtQuick 2.8 +import QtQuick.Controls 2.2 +import ProtonUI 1.0 +import ImportExportUI 1.0 + +Rectangle { + id: root + color : Style.exporting.background + radius : Style.exporting.boxRadius + border { + color : Style.exporting.line + width : Style.dialog.borderInput + } + property bool hasItems: true + + + Text { // placeholder + visible: !root.hasItems + anchors.centerIn: parent + color: Style.main.textDisabled + font { + pointSize: Style.dialog.fontSize * Style.pt + } + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + text: qsTr("No emails found for this address.","todo") + } + + + property string title : "" + + TextMetrics { + id: titleMetrics + text: root.title + elide: Qt.ElideMiddle + elideWidth: root.width - 4*Style.exporting.leftMargin + font { + pointSize: Style.dialog.fontSize * Style.pt + bold: true + } + } + + Rectangle { + id: header + anchors { + top: root.top + left: root.left + } + width : root.width + height : Style.dialog.fontSize*3 + color : Style.transparent + Rectangle { + anchors.bottom: parent.bottom + color : Style.exporting.line + height : Style.dialog.borderInput + width : parent.width + } + + Text { + anchors { + left : parent.left + leftMargin : 2*Style.exporting.leftMargin + verticalCenter : parent.verticalCenter + } + color: Style.dialog.text + font: titleMetrics.font + text: titleMetrics.elidedText + } + } + + + ListView { + id: listview + clip : true + orientation : ListView.Vertical + boundsBehavior : Flickable.StopAtBounds + model : structurePM + cacheBuffer : 10000 + + anchors { + left : root.left + right : root.right + bottom : root.bottom + top : header.bottom + margins : Style.dialog.borderInput + } + + ScrollBar.vertical: ScrollBar { + /* + policy: ScrollBar.AsNeeded + background : Rectangle { + color : Style.exporting.sliderBackground + radius : Style.exporting.boxRadius + } + contentItem : Rectangle { + color : Style.exporting.sliderForeground + radius : Style.exporting.boxRadius + implicitWidth : Style.main.rightMargin / 3 + } + */ + anchors { + right: parent.right + rightMargin: Style.main.rightMargin/4 + } + width: Style.main.rightMargin/3 + Accessible.ignored: true + } + + delegate: FolderRowButton { + width : root.width - 5*root.border.width + type : folderType + color : folderColor + title : folderName + isSelected : isFolderSelected + onClicked : { + //console.log("Clicked", folderId, isSelected) + structurePM.setFolderSelection(folderId,!isSelected) + } + } + + section.property: "folderType" + section.delegate: FolderRowButton { + isSection : true + width : root.width - 5*root.border.width + title : gui.folderTypeTitle(section) + isSelected : { + //console.log("section selected changed: ", section) + return section == gui.enums.folderTypeLabel ? structurePM.selectedLabels : structurePM.selectedFolders + } + onClicked : structurePM.selectType(section,!isSelected) + } + } +} diff --git a/internal/frontend/qml/ImportExportUI/FilterStructure.qml b/internal/frontend/qml/ImportExportUI/FilterStructure.qml new file mode 100644 index 00000000..3d452152 --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/FilterStructure.qml @@ -0,0 +1,55 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Filter only selected folders or labels +import QtQuick 2.8 +import QtQml.Models 2.2 + + +DelegateModel { + id: root + model : structurePM + //filterOnGroup : root.folderType + //delegate : root.delegate + groups : [ + DelegateModelGroup {name: gui.enums.folderTypeFolder ; includeByDefault: false}, + DelegateModelGroup {name: gui.enums.folderTypeLabel ; includeByDefault: false} + ] + + function updateFilter() { + //console.log("FilterModelDelegate::UpdateFilter") + // filter + var rowCount = root.items.count; + for (var iItem = 0; iItem < rowCount; iItem++) { + var entry = root.items.get(iItem); + entry.inLabel = ( + root.filterOnGroup == gui.enums.folderTypeLabel && + entry.model.folderType == gui.enums.folderTypeLabel + ) + entry.inFolder = ( + root.filterOnGroup == gui.enums.folderTypeFolder && + entry.model.folderType != gui.enums.folderTypeLabel + ) + /* + if (entry.inFolder && entry.model.folderId == selectedIDs) { + view.currentIndex = iItem + } + */ + //console.log("::::update filter:::::", iItem, entry.model.folderName, entry.inFolder, entry.inLabel) + } + } +} diff --git a/internal/frontend/qml/ImportExportUI/FolderRowButton.qml b/internal/frontend/qml/ImportExportUI/FolderRowButton.qml new file mode 100644 index 00000000..43e85554 --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/FolderRowButton.qml @@ -0,0 +1,99 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Checkbox row for folder selection +import QtQuick 2.8 +import QtQuick.Controls 2.2 +import ProtonUI 1.0 +import ImportExportUI 1.0 + +AccessibleButton { + id: root + + property bool isSection : false + property bool isSelected : false + property string title : "N/A" + property string type : "" + property color color : "black" + + height : Style.exporting.rowHeight + padding : 0.0 + anchors { + horizontalCenter: parent.horizontalCenter + } + + background: Rectangle { + color: isSection ? Style.exporting.background : Style.exporting.rowBackground + Rectangle { // line + anchors.bottom : parent.bottom + height : Style.dialog.borderInput + width : parent.width + color : Style.exporting.background + } + } + + contentItem: Rectangle { + color: "transparent" + id: content + Text { + id: checkbox + anchors { + verticalCenter : parent.verticalCenter + left : content.left + leftMargin : Style.exporting.leftMargin * (root.type == gui.enums.folderTypeSystem ? 1.0 : 2.0) + } + font { + family : Style.fontawesome.name + pointSize : Style.dialog.fontSize * Style.pt + } + color : isSelected ? Style.main.text : Style.main.textInactive + text : (isSelected ? Style.fa.check_square_o : Style.fa.square_o ) + } + + Text { // icon + id: folderIcon + visible: !isSection + anchors { + verticalCenter : parent.verticalCenter + left : checkbox.left + leftMargin : Style.dialog.fontSize + Style.exporting.leftMargin + } + color : root.type==gui.enums.folderTypeSystem ? Style.main.textBlue : root.color + font { + family : Style.fontawesome.name + pointSize : Style.dialog.fontSize * Style.pt + } + text : { + return gui.folderIcon(root.title.toLowerCase(), root.type) + } + } + + Text { + text: root.title + anchors { + verticalCenter : parent.verticalCenter + left : isSection ? checkbox.left : folderIcon.left + leftMargin : Style.dialog.fontSize + Style.exporting.leftMargin + } + font { + pointSize : Style.dialog.fontSize * Style.pt + bold: isSection + } + color: Style.exporting.text + } + } +} diff --git a/internal/frontend/qml/ImportExportUI/HelpView.qml b/internal/frontend/qml/ImportExportUI/HelpView.qml new file mode 100644 index 00000000..22e9e39d --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/HelpView.qml @@ -0,0 +1,129 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// List the settings + +import QtQuick 2.8 +import ProtonUI 1.0 +import ImportExportUI 1.0 + +Item { + id: root + + // must have wrapper + Rectangle { + id: wrapper + anchors.centerIn: parent + width: parent.width + height: parent.height + color: Style.main.background + + // content + Column { + anchors.horizontalCenter : parent.horizontalCenter + + + ButtonIconText { + id: manual + anchors.left: parent.left + text: qsTr("Setup Guide") + leftIcon.text : Style.fa.book + rightIcon.text : Style.fa.chevron_circle_right + rightIcon.font.pointSize : Style.settings.toggleSize * Style.pt + onClicked: go.openManual() + } + + ButtonIconText { + id: updates + anchors.left: parent.left + text: qsTr("Check for Updates") + leftIcon.text : Style.fa.refresh + rightIcon.text : Style.fa.chevron_circle_right + rightIcon.font.pointSize : Style.settings.toggleSize * Style.pt + onClicked: { + dialogGlobal.state="checkUpdates" + dialogGlobal.show() + dialogGlobal.confirmed() + } + } + + Rectangle { + anchors.horizontalCenter : parent.horizontalCenter + height: Math.max ( + aboutText.height + + Style.main.fontSize, + wrapper.height - ( + 2*manual.height + + creditsLink.height + + Style.main.fontSize + ) + ) + width: wrapper.width + color : Style.transparent + Text { + id: aboutText + anchors { + bottom: parent.bottom + horizontalCenter: parent.horizontalCenter + } + color: Style.main.textDisabled + horizontalAlignment: Qt.AlignHCenter + font.family : Style.fontawesome.name + text: "ProtonMail Import-Export Version "+go.getBackendVersion()+"\n"+Style.fa.copyright + " 2020 Proton Technologies AG" + } + } + + Row { + anchors.horizontalCenter : parent.horizontalCenter + spacing : Style.main.dummy + + Text { + id: creditsLink + text : qsTr("Credits", "link to click on to view list of credited libraries") + color : Style.main.textDisabled + font.pointSize: Style.main.fontSize * Style.pt + font.underline: true + MouseArea { + anchors.fill: parent + onClicked : { + winMain.dialogCredits.show() + } + cursorShape: Qt.PointingHandCursor + } + } + + + Text { + id: releaseNotes + text : qsTr("Release notes", "link to click on to view release notes for this version of the app") + color : Style.main.textDisabled + font.pointSize: Style.main.fontSize * Style.pt + font.underline: true + MouseArea { + anchors.fill: parent + onClicked : { + go.getLocalVersionInfo() + winMain.dialogVersionInfo.show() + } + cursorShape: Qt.PointingHandCursor + } + } + } + } + } +} + diff --git a/internal/frontend/qml/ImportExportUI/IEStyle.qml b/internal/frontend/qml/ImportExportUI/IEStyle.qml new file mode 100644 index 00000000..71578633 --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/IEStyle.qml @@ -0,0 +1,106 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Adjust Bridge Style + +import QtQuick 2.8 +import ImportExportUI 1.0 +import ProtonUI 1.0 + +Item { + Component.onCompleted : { + //Style.refdpi = go.goos == "darwin" ? 86.0 : 96.0 + Style.pt = go.goos == "darwin" ? 93/Style.dpi : 80/Style.dpi + + Style.main.background = "#fff" + Style.main.text = "#505061" + Style.main.textInactive = "#686876" + Style.main.line = "#dddddd" + Style.main.width = 884 * Style.px + Style.main.height = 422 * Style.px + Style.main.leftMargin = 25 * Style.px + Style.main.rightMargin = 25 * Style.px + + Style.title.background = Style.main.text + Style.title.text = Style.main.background + + Style.tabbar.background = "#3D3A47" + Style.tabbar.rightButton = "add account" + Style.tabbar.spacingButton = 45*Style.px + + Style.accounts.backgroundExpanded = "#fafafa" + Style.accounts.backgroundAddrRow = "#fff" + Style.accounts.leftMargin2 = Style.main.width/2 + Style.accounts.leftMargin3 = 5.5*Style.main.width/8 + + + Style.dialog.background = "#fff" + Style.dialog.text = Style.main.text + Style.dialog.line = "#e2e2e2" + Style.dialog.fontSize = 12 * Style.px + Style.dialog.heightInput = 2.2*Style.dialog.fontSize + Style.dialog.heightButton = Style.dialog.heightInput + Style.dialog.borderInput = 1 * Style.px + + Style.bubble.background = "#595966" + Style.bubble.paneBackground = "#454553" + Style.bubble.text = "#fff" + Style.bubble.width = 310 * Style.px + Style.bubble.widthPane = 36 * Style.px + Style.bubble.iconSize = 14 * Style.px + + + // colors: + // text: #515061 + // tick: #686876 + // blue icon: #9396cc + // row bck: #f8f8f9 + // line: #ddddde or #e2e2e2 + // + // slider bg: #e6e6e6 + // slider fg: #515061 + // info icon: #c3c3c8 + // input border: #ebebeb + // + // bubble color: #595966 + // bubble pane: #454553 + // bubble text: #fff + // + // indent folder + // + // Dimensions: + // full width: 882px + // leftMargin: 25px + // rightMargin: 25px + // rightMargin: 25px + // middleSeparator: 69px + // width folders: 416px or (width - separators) /2 + // width output: 346px or (width - separators) /2 + // + // height from top to input begin: 78px + // heightSeparator: 27px + // height folder input: 26px + // + // buble width: 309px + // buble left pane icon: 14px + // buble left pane width: 36px or (2.5 icon width) + // buble height: 46px + // buble arrow height: 12px + // buble arrow width: 14px + // buble radius: 3-4px + } +} diff --git a/internal/frontend/qml/ImportExportUI/ImportDelegate.qml b/internal/frontend/qml/ImportExportUI/ImportDelegate.qml new file mode 100644 index 00000000..ebb72d35 --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/ImportDelegate.qml @@ -0,0 +1,164 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// List of import folder and their target +import QtQuick 2.8 +import QtQuick.Controls 2.2 +import ProtonUI 1.0 +import ImportExportUI 1.0 + +Rectangle { + id: root + color: Style.importing.rowBackground + height: 40 + width: 300 + property real leftMargin1 : folderIcon.x - root.x + property real leftMargin2 : selectFolder.x - root.x + property real nameWidth : { + var available = root.width + available -= rowPlacement.children.length * rowPlacement.spacing // spacing between places + available -= 3*rowPlacement.leftPadding // left, and 2x right + available -= folderIcon.width + available -= arrowIcon.width + available -= dateRangeMenu.width + return available/3.3 // source folder label, target folder menu, target labels menu, and 0.3x label list + } + property real iconWidth : nameWidth*0.3 + + property bool isSourceSelected: targetFolderID!="" + property string lastTargetFolder: "6" // Archive + property string lastTargetLabels: "" // no flag by default + + + Rectangle { + id: line + anchors { + left : parent.left + right : parent.right + bottom : parent.bottom + } + height : Style.main.border * 2 + color : Style.importing.rowLine + } + + Row { + id: rowPlacement + spacing: Style.dialog.spacing + leftPadding: Style.dialog.spacing*2 + anchors.verticalCenter : parent.verticalCenter + + CheckBoxLabel { + id: checkBox + anchors.verticalCenter : parent.verticalCenter + checked: root.isSourceSelected + + onClicked: root.toggleImport() + } + + Text { + id: folderIcon + text : gui.folderIcon(folderName, gui.enums.folderTypeFolder) + anchors.verticalCenter : parent.verticalCenter + color: root.isSourceSelected ? Style.main.text : Style.main.textDisabled + font { + family : Style.fontawesome.name + pointSize : Style.dialog.fontSize * Style.pt + } + } + + Text { + text : folderName + width: nameWidth + elide: Text.ElideRight + anchors.verticalCenter : parent.verticalCenter + color: folderIcon.color + font.pointSize : Style.dialog.fontSize * Style.pt + } + + Text { + id: arrowIcon + text : Style.fa.arrow_right + anchors.verticalCenter : parent.verticalCenter + color: Style.main.text + font { + family : Style.fontawesome.name + pointSize : Style.dialog.fontSize * Style.pt + } + } + + SelectFolderMenu { + id: selectFolder + sourceID: folderId + selectedIDs: targetFolderID + width: nameWidth + anchors.verticalCenter : parent.verticalCenter + onDoNotImport: root.toggleImport() + onImportToFolder: root.importToFolder(newTargetID) + } + + SelectLabelsMenu { + sourceID: folderId + selectedIDs: targetLabelIDs + width: nameWidth + anchors.verticalCenter : parent.verticalCenter + enabled: root.isSourceSelected + } + + LabelIconList { + selectedIDs: targetLabelIDs + width: iconWidth + anchors.verticalCenter : parent.verticalCenter + enabled: root.isSourceSelected + } + + DateRangeMenu { + id: dateRangeMenu + sourceID: folderId + + enabled: root.isSourceSelected + anchors.verticalCenter : parent.verticalCenter + } + } + + + function importToFolder(newTargetID) { + if (root.isSourceSelected) { + structureExternal.setTargetFolderID(folderId,newTargetID) + } else { + lastTargetFolder = newTargetID + toggleImport() + } + } + + function toggleImport() { + if (root.isSourceSelected) { + lastTargetFolder = targetFolderID + lastTargetLabels = targetLabelIDs + structureExternal.setTargetFolderID(folderId,"") + return Qt.Unchecked + } else { + structureExternal.setTargetFolderID(folderId,lastTargetFolder) + var labelsSplit = lastTargetLabels.split(";") + for (var labelIndex in labelsSplit) { + var labelID = labelsSplit[labelIndex] + structureExternal.addTargetLabelID(folderId,labelID) + } + return Qt.Checked + } + } + +} diff --git a/internal/frontend/qml/ImportExportUI/ImportReport.qml b/internal/frontend/qml/ImportExportUI/ImportReport.qml new file mode 100644 index 00000000..eab56bef --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/ImportReport.qml @@ -0,0 +1,216 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Import report modal +import QtQuick 2.11 +import QtQuick.Controls 2.4 +import ProtonUI 1.0 +import ImportExportUI 1.0 + +Rectangle { + id: root + color: "#aa101021" + visible: false + + MouseArea { // disable bellow + anchors.fill: root + hoverEnabled: true + } + + Rectangle { + id:background + color: Style.main.background + anchors { + fill : root + topMargin : Style.main.rightMargin + leftMargin : 2*Style.main.rightMargin + rightMargin : 2*Style.main.rightMargin + bottomMargin : 2.5*Style.main.rightMargin + } + + ClickIconText { + anchors { + top : parent.top + right : parent.right + margins : .5* Style.main.rightMargin + } + iconText : Style.fa.times + text : "" + textColor : Style.main.textBlue + onClicked : root.hide() + Accessible.description : qsTr("Close dialog %1", "Click to exit modal.").arg(title.text) + } + + Text { + id: title + text : qsTr("List of errors") + font { + pointSize: Style.dialog.titleSize * Style.pt + } + anchors { + top : parent.top + topMargin : 0.5*Style.main.rightMargin + horizontalCenter : parent.horizontalCenter + } + } + + ListView { + id: errorView + anchors { + left : parent.left + right : parent.right + top : title.bottom + bottom : detailBtn.top + margins : Style.main.rightMargin + } + + clip : true + flickableDirection : Flickable.HorizontalAndVerticalFlick + contentWidth : errorView.rWall + boundsBehavior : Flickable.StopAtBounds + + ScrollBar.vertical: ScrollBar { + anchors { + right : parent.right + top : parent.top + rightMargin : Style.main.rightMargin/4 + topMargin : Style.main.rightMargin + } + width: Style.main.rightMargin/3 + Accessible.ignored: true + } + ScrollBar.horizontal: ScrollBar { + anchors { + bottom : parent.bottom + right : parent.right + bottomMargin : Style.main.rightMargin/4 + rightMargin : Style.main.rightMargin + } + height: Style.main.rightMargin/3 + Accessible.ignored: true + } + + + + property real rW1 : 150 *Style.px + property real rW2 : 150 *Style.px + property real rW3 : 100 *Style.px + property real rW4 : 150 *Style.px + property real rW5 : 550 *Style.px + property real rWall : errorView.rW1+errorView.rW2+errorView.rW3+errorView.rW4+errorView.rW5 + property real pH : .5*Style.main.rightMargin + + model : errorList + delegate : Rectangle { + width : Math.max(errorView.width, row.width) + height : row.height + + Row { + id: row + + spacing : errorView.pH + leftPadding : errorView.pH + rightPadding : errorView.pH + topPadding : errorView.pH + bottomPadding : errorView.pH + + ImportReportCell { width : errorView.rW1; text : mailSubject } + ImportReportCell { width : errorView.rW2; text : mailDate } + ImportReportCell { width : errorView.rW3; text : inputFolder } + ImportReportCell { width : errorView.rW4; text : mailFrom } + ImportReportCell { width : errorView.rW5; text : errorMessage } + } + + Rectangle { + color : Style.main.line + height : .8*Style.px + width : parent.width + anchors.left : parent.left + anchors.bottom : parent.bottom + } + } + + headerPositioning: ListView.OverlayHeader + header: Rectangle { + height : viewHeader.height + width : Math.max(errorView.width, viewHeader.width) + color : Style.accounts.backgroundExpanded + z : 2 + + Row { + id: viewHeader + + spacing : errorView.pH + leftPadding : errorView.pH + rightPadding : errorView.pH + topPadding : .5*errorView.pH + bottomPadding : .5*errorView.pH + + ImportReportCell { width : errorView.rW1 ; text : qsTr ( "SUBJECT" ); isHeader: true } + ImportReportCell { width : errorView.rW2 ; text : qsTr ( "DATE/TIME" ); isHeader: true } + ImportReportCell { width : errorView.rW3 ; text : qsTr ( "FOLDER" ); isHeader: true } + ImportReportCell { width : errorView.rW4 ; text : qsTr ( "FROM" ); isHeader: true } + ImportReportCell { width : errorView.rW5 ; text : qsTr ( "ERROR" ); isHeader: true } + } + + Rectangle { + color : Style.main.line + height : .8*Style.px + width : parent.width + anchors.left : parent.left + anchors.bottom : parent.bottom + } + } + } + + Rectangle { + anchors{ + fill : errorView + margins : -radius + } + radius : 2* Style.px + color : Style.transparent + border { + width : Style.px + color : Style.main.line + } + } + + ButtonRounded { + id: detailBtn + fa_icon : Style.fa.file_text + text : qsTr("Detailed file") + color_main : Style.dialog.textBlue + onClicked : go.importLogFileName == "" ? go.openLogs() : go.openReport() + + anchors { + bottom : parent.bottom + bottomMargin : 0.5*Style.main.rightMargin + horizontalCenter : parent.horizontalCenter + } + } + } + + + function show() { + root.visible = true + } + + function hide() { + root.visible = false + } +} diff --git a/internal/frontend/qml/ImportExportUI/ImportReportCell.qml b/internal/frontend/qml/ImportExportUI/ImportReportCell.qml new file mode 100644 index 00000000..4df5d5f3 --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/ImportReportCell.qml @@ -0,0 +1,67 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Import report modal +import QtQuick 2.11 +import QtQuick.Controls 2.4 +import ProtonUI 1.0 +import ImportExportUI 1.0 + + +Rectangle { + id: root + + property alias text : cellText.text + property bool isHeader : false + property bool isHovered : false + property bool isWider : cellText.contentWidth > root.width + + width : 20*Style.px + height : cellText.height + z : root.isHovered ? 3 : 1 + color : Style.transparent + + Rectangle { + anchors { + fill : cellText + margins : -2*Style.px + } + color : root.isWider ? Style.main.background : Style.transparent + border { + color : root.isWider ? Style.main.textDisabled : Style.transparent + width : Style.px + } + } + + Text { + id: cellText + color : root.isHeader ? Style.main.textDisabled : Style.main.text + elide : root.isHovered ? Text.ElideNone : Text.ElideRight + width : root.isHovered ? cellText.contentWidth : root.width + font { + pointSize : Style.main.textSize * Style.pt + family : Style.fontawesome.name + } + } + + MouseArea { + anchors.fill : root + hoverEnabled : !root.isHeader + onEntered : { root.isHovered = true } + onExited : { root.isHovered = false } + } +} diff --git a/internal/frontend/qml/ImportExportUI/ImportSourceButton.qml b/internal/frontend/qml/ImportExportUI/ImportSourceButton.qml new file mode 100644 index 00000000..934c59cd --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/ImportSourceButton.qml @@ -0,0 +1,89 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Export dialog +import QtQuick 2.8 +import QtQuick.Controls 2.2 +import ProtonUI 1.0 +import ImportExportUI 1.0 + + + +Button { + id: root + + width : 200 + height : icon.height + 4*tag.height + scale : pressed ? 0.95 : 1.0 + + property string iconText : Style.fa.ban + + background: Rectangle { color: "transparent" } + + contentItem: Rectangle { + id: wrapper + color: "transparent" + + Image { + id: icon + anchors { + bottom : wrapper.bottom + bottomMargin : tag.height*2.5 + horizontalCenter : wrapper.horizontalCenter + } + fillMode : Image.PreserveAspectFit + width : Style.main.fontSize * 7 + mipmap : true + source : "images/"+iconText+".png" + + } + + Row { + spacing: Style.dialog.spacing + anchors { + bottom : wrapper.bottom + horizontalCenter : wrapper.horizontalCenter + } + + Text { + id: tag + + text : Style.fa.plus_circle + color : Qt.lighter( Style.dialog.textBlue, root.enabled ? 1.0 : 1.5) + + font { + family : Style.fontawesome.name + pointSize : Style.main.fontSize * Style.pt * 1.2 + } + } + + Text { + text : root.text + color: tag.color + + font { + family : tag.font.family + pointSize : tag.font.pointSize + weight : Font.DemiBold + underline : true + } + } + } + } +} + + diff --git a/internal/frontend/qml/ImportExportUI/ImportStructure.qml b/internal/frontend/qml/ImportExportUI/ImportStructure.qml new file mode 100644 index 00000000..da94e46f --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/ImportStructure.qml @@ -0,0 +1,149 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// List of import folder and their target +import QtQuick 2.8 +import QtQuick.Controls 2.2 +import ProtonUI 1.0 +import ImportExportUI 1.0 + +Rectangle { + id: root + property string titleFrom + property string titleTo + property bool hasItems: true + + color : Style.transparent + + Rectangle { + anchors.fill: root + radius : Style.dialog.radiusButton + color : Style.transparent + border { + color : Style.main.line + width : 1.5*Style.dialog.borderInput + } + + + Text { // placeholder + visible: !root.hasItems + anchors.centerIn: parent + color: Style.main.textDisabled + font { + pointSize: Style.dialog.fontSize * Style.pt + } + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + text: qsTr("No emails found for this source.","todo") + } + + } + + anchors { + left : parent.left + right : parent.right + top : parent.top + bottom : parent.bottom + + leftMargin : Style.main.leftMargin + rightMargin : Style.main.leftMargin + topMargin : Style.main.topMargin + bottomMargin : Style.main.bottomMargin + } + + ListView { + id: listview + clip : true + orientation : ListView.Vertical + boundsBehavior : Flickable.StopAtBounds + model : structureExternal + cacheBuffer : 10000 + delegate : ImportDelegate { + width: root.width + } + + anchors { + top: titleBox.bottom + bottom: root.bottom + left: root.left + right: root.right + margins : Style.dialog.borderInput + bottomMargin: Style.dialog.radiusButton + } + + ScrollBar.vertical: ScrollBar { + anchors { + right: parent.right + rightMargin: Style.main.rightMargin/4 + } + width: Style.main.rightMargin/3 + Accessible.ignored: true + } + } + + Rectangle { + id: titleBox + anchors { + top: parent.top + left: parent.left + right: parent.right + } + height: Style.main.fontSize *2 + color : Style.transparent + + Text { + id: textTitleFrom + anchors { + left: parent.left + verticalCenter: parent.verticalCenter + leftMargin: { + if (listview.currentIndex<0) return 0 + else return listview.currentItem.leftMargin1 + } + } + text: ""+qsTr("From:")+" " + root.titleFrom + color: Style.main.text + width: listview.currentItem === null ? 0 : (listview.currentItem.leftMargin2 - listview.currentItem.leftMargin1 - Style.dialog.spacing) + elide: Text.ElideMiddle + } + + Text { + id: textTitleTo + anchors { + left: parent.left + verticalCenter: parent.verticalCenter + leftMargin: { + if (listview.currentIndex<0) return root.width/3 + else return listview.currentItem.leftMargin2 + } + } + text: ""+qsTr("To:")+" " + root.titleTo + color: Style.main.text + } + } + + Rectangle { + id: line + anchors { + left : titleBox.left + right : titleBox.right + top : titleBox.bottom + } + height: Style.dialog.borderInput + color: Style.main.line + } +} diff --git a/internal/frontend/qml/ImportExportUI/InlineDateRange.qml b/internal/frontend/qml/ImportExportUI/InlineDateRange.qml new file mode 100644 index 00000000..017b0eb4 --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/InlineDateRange.qml @@ -0,0 +1,128 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// input for date range +import QtQuick 2.8 +import QtQuick.Controls 2.2 +import ProtonUI 1.0 +import ImportExportUI 1.0 + + +Row { + id: dateRange + + property var structure : structureExternal + property string sourceID : structureExternal.getID ( -1 ) + + property alias allDates : allDatesBox.checked + property alias inputDateFrom : inputDateFrom + property alias inputDateTo : inputDateTo + + property alias labelWidth: label.width + + function setRange() {common.setRange()} + function applyRange() {common.applyRange()} + + DateRangeFunctions {id:common} + + spacing: Style.dialog.spacing*2 + + Text { + id: label + anchors.verticalCenter: parent.verticalCenter + text : qsTr("Date range") + font { + bold: true + family: Style.fontawesome.name + pointSize: Style.main.fontSize * Style.pt + } + color: Style.main.text + } + + DateInput { + id: inputDateFrom + label: "" + anchors.verticalCenter: parent.verticalCenter + currentDate: new Date(0) // default epoch start + maxDate: inputDateTo.currentDate + } + + Text { + text : Style.fa.arrows_h + anchors.verticalCenter: parent.verticalCenter + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + color: Style.main.text + font.family: Style.fontawesome.name + } + + DateInput { + id: inputDateTo + label: "" + anchors.verticalCenter: parent.verticalCenter + currentDate: new Date() // default now + minDate: inputDateFrom.currentDate + } + + CheckBoxLabel { + id: allDatesBox + text : qsTr("All dates") + anchors.verticalCenter : parent.verticalCenter + checkedSymbol : Style.fa.toggle_on + uncheckedSymbol : Style.fa.toggle_off + uncheckedColor : Style.main.textDisabled + symbolPointSize : Style.dialog.iconSize * Style.pt * 1.1 + spacing : Style.dialog.spacing*2 + + TextMetrics { + id: metrics + text: allDatesBox.checkedSymbol + font { + family: Style.fontawesome.name + pointSize: allDatesBox.symbolPointSize + } + } + + + Rectangle { + color: allDatesBox.checked ? dotBackground.color : Style.exporting.sliderBackground + width: metrics.width*0.9 + height: metrics.height*0.6 + radius: height/2 + z: -1 + + anchors { + left: allDatesBox.left + verticalCenter: allDatesBox.verticalCenter + leftMargin: 0.05 * metrics.width + } + + Rectangle { + id: dotBackground + color : Style.exporting.background + height : parent.height + width : height + radius : height/2 + anchors { + left : parent.left + verticalCenter : parent.verticalCenter + } + + } + } + } +} diff --git a/internal/frontend/qml/ImportExportUI/InlineLabelSelect.qml b/internal/frontend/qml/ImportExportUI/InlineLabelSelect.qml new file mode 100644 index 00000000..e7ee4680 --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/InlineLabelSelect.qml @@ -0,0 +1,227 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// input for date range +import QtQuick 2.8 +import QtQuick.Controls 2.2 +import ProtonUI 1.0 +import ImportExportUI 1.0 + + +Row { + id: root + spacing: Style.dialog.spacing + + property alias labelWidth : label.width + + property string labelName : "" + property string labelColor : "" + property alias labelSelected : masterLabelCheckbox.checked + + Text { + id: label + text : qsTr("Add import label") + font { + bold: true + family: Style.fontawesome.name + pointSize: Style.main.fontSize * Style.pt + } + color: Style.main.text + anchors.verticalCenter: parent.verticalCenter + } + + InfoToolTip { + info: qsTr( "When master import lablel is selected then all imported email will have this label.", "Tooltip text for master import label") + anchors.verticalCenter: parent.verticalCenter + } + + CheckBoxLabel { + id: masterLabelCheckbox + text : "" + anchors.verticalCenter : parent.verticalCenter + checkedSymbol : Style.fa.toggle_on + uncheckedSymbol : Style.fa.toggle_off + uncheckedColor : Style.main.textDisabled + symbolPointSize : Style.dialog.iconSize * Style.pt * 1.1 + spacing : Style.dialog.spacing*2 + + TextMetrics { + id: metrics + text: masterLabelCheckbox.checkedSymbol + font { + family: Style.fontawesome.name + pointSize: masterLabelCheckbox.symbolPointSize + } + } + + + Rectangle { + color: parent.checked ? dotBackground.color : Style.exporting.sliderBackground + width: metrics.width*0.9 + height: metrics.height*0.6 + radius: height/2 + z: -1 + + anchors { + left: masterLabelCheckbox.left + verticalCenter: masterLabelCheckbox.verticalCenter + leftMargin: 0.05 * metrics.width + } + + Rectangle { + id: dotBackground + color : Style.exporting.background + height : parent.height + width : height + radius : height/2 + anchors { + left : parent.left + verticalCenter : parent.verticalCenter + } + + } + } + } + + Rectangle { + // label + color : Style.transparent + radius : Style.dialog.radiusButton + border { + color : Style.dialog.line + width : Style.dialog.borderInput + } + anchors.verticalCenter : parent.verticalCenter + + scale: area.pressed ? 0.95 : 1 + + width: content.width + height: content.height + + + Row { + id: content + + spacing : Style.dialog.spacing + padding : Style.dialog.spacing + + anchors.verticalCenter: parent.verticalCenter + + // label icon color + Text { + text: Style.fa.tag + color: root.labelSelected ? root.labelColor : Style.dialog.line + anchors.verticalCenter: parent.verticalCenter + font { + family: Style.fontawesome.name + pointSize: Style.main.fontSize * Style.pt + } + } + + TextMetrics { + id:labelMetrics + text: root.labelName + elide: Text.ElideRight + elideWidth:gui.winMain.width*0.303 + + font { + pointSize: Style.main.fontSize * Style.pt + family: Style.fontawesome.name + } + } + + // label text + Text { + text: labelMetrics.elidedText + color: root.labelSelected ? Style.dialog.text : Style.dialog.line + font: labelMetrics.font + anchors.verticalCenter: parent.verticalCenter + } + + // edit icon + Text { + text: Style.fa.edit + color: root.labelSelected ? Style.main.textBlue : Style.dialog.line + anchors.verticalCenter: parent.verticalCenter + font { + family: Style.fontawesome.name + pointSize: Style.main.fontSize * Style.pt + } + } + } + + MouseArea { + id: area + anchors.fill: parent + enabled: root.labelSelected + onClicked : { + if (!root.labelSelected) return + // NOTE: "createLater" is hack + winMain.popupFolderEdit.show(root.labelName, "createLater", root.labelColor, gui.enums.folderTypeLabel, "") + } + } + } + + function reset(){ + labelColor = go.leastUsedColor() + labelName = qsTr("Imported", "default name of global label followed by date") + " " + gui.niceDateTime() + labelSelected=true + } + + Connections { + target: winMain.popupFolderEdit + + onEdited : { + if (newName!="") root.labelName = newName + if (newColor!="") root.labelColor = newColor + } + } + + + /* + SelectLabelsMenu { + id: labelMenu + width : winMain.width/5 + sourceID : root.sourceID + selectedIDs : root.structure.getTargetLabelIDs(root.sourceID) + anchors.verticalCenter: parent.verticalCenter + } + + LabelIconList { + id: iconList + selectedIDs : root.structure.getTargetLabelIDs(root.sourceID) + anchors.verticalCenter: parent.verticalCenter + } + + + Connections { + target: structureExternal + onDataChanged: { + iconList.selectedIDs = root.structure.getTargetLabelIDs(root.sourceID) + labelMenu.selectedIDs = root.structure.getTargetLabelIDs(root.sourceID) + } + } + + Connections { + target: structurePM + onDataChanged:{ + iconList.selectedIDs = root.structure.getTargetLabelIDs(root.sourceID) + labelMenu.selectedIDs = root.structure.getTargetLabelIDs(root.sourceID) + } + } + */ +} diff --git a/internal/frontend/qml/ImportExportUI/LabelIconList.qml b/internal/frontend/qml/ImportExportUI/LabelIconList.qml new file mode 100644 index 00000000..bf6ab759 --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/LabelIconList.qml @@ -0,0 +1,96 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// List of icons for selected folders +import QtQuick 2.8 +import QtQuick.Controls 2.2 +import QtQml.Models 2.2 +import ProtonUI 1.0 +import ImportExportUI 1.0 + +Rectangle { + id: root + width: Style.main.fontSize * 2 + height: metrics.height + property string selectedIDs : "" + color: "transparent" + + + + DelegateModel { + id: selectedLabels + filterOnGroup: "selected" + groups: DelegateModelGroup { + id: selected + name: "selected" + includeByDefault: true + } + model : structurePM + delegate : Text { + text : metrics.text + font : metrics.font + color : folderColor===undefined ? "#000": folderColor + } + } + + function updateFilter() { + var selected = root.selectedIDs.split(";") + var rowCount = selectedLabels.items.count + //console.log(" log ::", root.selectedIDs, rowCount, selectedLabels.model) + // filter + for (var iItem = 0; iItem < rowCount; iItem++) { + var entry = selectedLabels.items.get(iItem); + //console.log(" log filter ", iItem, rowCount, entry.model.folderId, entry.model.folderType, selected[iSel], entry.inSelected ) + for (var iSel in selected) { + entry.inSelected = ( + entry.model.folderType == gui.enums.folderTypeLabel && + entry.model.folderId == selected[iSel] + ) + if (entry.inSelected) break // found match, skip rest + } + } + } + + TextMetrics { + id: metrics + text: Style.fa.tag + font { + pointSize: Style.main.fontSize * Style.pt + family: Style.fontawesome.name + } + } + + Row { + anchors.left : root.left + spacing : { + var n = Math.max(2,selectedLabels.count) + var tagWidth = Math.max(1.0,metrics.width) + var space = Math.min(1*Style.px, (root.width - n*tagWidth)/(n-1)) // not more than 1px + space = Math.max(space,-tagWidth) // not less than tag width + return space + } + + Repeater { + model: selectedLabels + } + } + + Component.onCompleted: root.updateFilter() + onSelectedIDsChanged: root.updateFilter() + Connections { target: structurePM; onDataChanged:root.updateFilter() } +} + diff --git a/internal/frontend/qml/ImportExportUI/MainWindow.qml b/internal/frontend/qml/ImportExportUI/MainWindow.qml new file mode 100644 index 00000000..2531c803 --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/MainWindow.qml @@ -0,0 +1,473 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// This is main window + +import QtQuick 2.8 +import QtQuick.Window 2.2 +import QtQuick.Controls 2.1 +import QtQuick.Layouts 1.3 +import ImportExportUI 1.0 +import ProtonUI 1.0 + + +// Main Window +Window { + id : root + property alias tabbar : tabbar + property alias viewContent : viewContent + property alias viewAccount : viewAccount + property alias dialogAddUser : dialogAddUser + property alias dialogGlobal : dialogGlobal + property alias dialogCredits : dialogCredits + property alias dialogVersionInfo : dialogVersionInfo + property alias dialogUpdate : dialogUpdate + property alias popupMessage : popupMessage + property alias popupFolderEdit : popupFolderEdit + property alias updateState : infoBar.state + property alias dialogExport : dialogExport + property alias dialogImport : dialogImport + property alias addAccountTip : addAccountTip + property int heightContent : height-titleBar.height + + property real innerWindowBorder : go.goos=="darwin" ? 0 : Style.main.border + + // main window appearance + width : Style.main.width + height : Style.main.height + flags : go.goos=="darwin" ? Qt.Window : Qt.Window | Qt.FramelessWindowHint + color: go.goos=="windows" ? Style.main.background : Style.transparent + title: go.programTitle + + minimumWidth : Style.main.width + minimumHeight : Style.main.height + + property bool isOutdateVersion : root.updateState == "forceUpgrade" + + property bool activeContent : + !dialogAddUser .visible && + !dialogCredits .visible && + !dialogVersionInfo .visible && + !dialogGlobal .visible && + !dialogUpdate .visible && + !dialogImport .visible && + !dialogExport .visible && + !popupFolderEdit .visible && + !popupMessage .visible + + Accessible.role: Accessible.Grouping + Accessible.description: qsTr("Window %1").arg(title) + Accessible.name: Accessible.description + + WindowTitleBar { + id: titleBar + window: root + visible: go.goos!="darwin" + } + + Rectangle { + anchors { + top : titleBar.bottom + left : parent.left + right : parent.right + bottom : parent.bottom + } + color: Style.title.background + } + + InformationBar { + id: infoBar + anchors { + left : parent.left + right : parent.right + top : titleBar.bottom + leftMargin: innerWindowBorder + rightMargin: innerWindowBorder + } + } + + TabLabels { + id: tabbar + currentIndex : 0 + enabled: root.activeContent + anchors { + top : infoBar.bottom + right : parent.right + left : parent.left + leftMargin: innerWindowBorder + rightMargin: innerWindowBorder + } + model: [ + { "title" : qsTr("Import/Export" , "title of tab that shows account list" ), "iconText": Style.fa.home }, + { "title" : qsTr("Settings" , "title of tab that allows user to change settings" ), "iconText": Style.fa.cogs }, + { "title" : qsTr("Help" , "title of tab that shows the help menu" ), "iconText": Style.fa.life_ring } + ] + } + + // Content of tabs + StackLayout { + id: viewContent + enabled: root.activeContent + // dimensions + anchors { + left : parent.left + right : parent.right + top : tabbar.bottom + bottom : parent.bottom + leftMargin: innerWindowBorder + rightMargin: innerWindowBorder + bottomMargin: innerWindowBorder + } + // attributes + currentIndex : { return root.tabbar.currentIndex} + clip : true + // content + AccountView { + id : viewAccount + onAddAccount : dialogAddUser.show() + model : accountsModel + hasFooter : false + delegate : AccountDelegate { + row_width : viewContent.width + } + } + SettingsView { id: viewSettings; } + HelpView { id: viewHelp; } + } + + + // Bubble prevent action + Rectangle { + anchors { + left: parent.left + right: parent.right + top: titleBar.bottom + bottom: parent.bottom + } + visible: bubbleNote.visible + color: "#aa222222" + MouseArea { + anchors.fill: parent + hoverEnabled: true + } + } + BubbleNote { + id : bubbleNote + visible : false + Component.onCompleted : { + bubbleNote.place(0) + } + } + + BubbleNote { + id:addAccountTip + anchors.topMargin: viewAccount.separatorNoAccount - 2*Style.main.fontSize + text : qsTr("Click here to start", "on first launch, this is displayed above the Add Account button to tell the user what to do first") + state: (go.isFirstStart && viewAccount.numAccounts==0 && root.viewContent.currentIndex==0) ? "Visible" : "Invisible" + bubbleColor: Style.main.textBlue + + Component.onCompleted : { + addAccountTip.place(-1) + } + enabled: false + + states: [ + State { + name: "Visible" + // hack: opacity 100% makes buttons dialog windows quit wrong color + PropertyChanges{target: addAccountTip; opacity: 0.999; visible: true} + }, + State { + name: "Invisible" + PropertyChanges{target: addAccountTip; opacity: 0.0; visible: false} + } + ] + + transitions: [ + Transition { + from: "Visible" + to: "Invisible" + + SequentialAnimation{ + NumberAnimation { + target: addAccountTip + property: "opacity" + duration: 0 + easing.type: Easing.InOutQuad + } + NumberAnimation { + target: addAccountTip + property: "visible" + duration: 0 + } + } + }, + Transition { + from: "Invisible" + to: "Visible" + SequentialAnimation{ + NumberAnimation { + target: addAccountTip + property: "visible" + duration: 300 + } + NumberAnimation { + target: addAccountTip + property: "opacity" + duration: 500 + easing.type: Easing.InOutQuad + } + } + } + ] + } + + + // Dialogs + + DialogAddUser { + id: dialogAddUser + + anchors { + top : infoBar.bottom + bottomMargin: innerWindowBorder + leftMargin: innerWindowBorder + rightMargin: innerWindowBorder + } + + onCreateAccount: Qt.openUrlExternally("https://protonmail.com/signup") + } + + DialogUpdate { + id: dialogUpdate + + title: root.isOutdateVersion ? + qsTr("%1 is outdated", "title of outdate dialog").arg(go.programTitle): + qsTr("%1 update to %2", "title of update dialog").arg(go.programTitle).arg(go.newversion) + introductionText: { + if (root.isOutdateVersion) { + if (go.goos=="linux") { + return qsTr('You are using an outdated version of our software.
+ Please dowload and install the latest version to continue using %1.

+ %2', + "Message for force-update in Linux").arg(go.programTitle).arg(go.landingPage) + } else { + return qsTr('You are using an outdated version of our software.
+ Please dowload and install the latest version to continue using %1.

+ You can continue with update or download and install the new version manually from

+ %2', + "Message for force-update in Win/Mac").arg(go.programTitle).arg(go.landingPage) + } + } else { + if (go.goos=="linux") { + return qsTr('New version of %1 is available.
+ Check release notes to learn what is new in %3.
+ Use your package manager to update or download and install new version manually from

+ %4', + "Message for update in Linux").arg(go.programTitle).arg("releaseNotes").arg(go.newversion).arg(go.landingPage) + } else { + return qsTr('New version of %1 is available.
+ Check release notes to learn what is new in %3.
+ You can continue with update or download and install new version manually from

+ %4', + "Message for update in Win/Mac").arg(go.programTitle).arg("releaseNotes").arg(go.newversion).arg(go.landingPage) + } + } + } + } + + + DialogExport { + id: dialogExport + anchors { + top : infoBar.bottom + bottomMargin: innerWindowBorder + leftMargin: innerWindowBorder + rightMargin: innerWindowBorder + } + + } + + DialogImport { + id: dialogImport + anchors { + top : infoBar.bottom + bottomMargin: innerWindowBorder + leftMargin: innerWindowBorder + rightMargin: innerWindowBorder + } + + } + + Dialog { + id: dialogCredits + anchors { + top : infoBar.bottom + bottomMargin: innerWindowBorder + leftMargin: innerWindowBorder + rightMargin: innerWindowBorder + } + + title: qsTr("Credits", "title for list of credited libraries") + + Credits { } + } + + Dialog { + id: dialogVersionInfo + anchors { + top : infoBar.bottom + bottomMargin: innerWindowBorder + leftMargin: innerWindowBorder + rightMargin: innerWindowBorder + } + property bool checkVersion : false + title: qsTr("Information about", "title of release notes page") + " v" + go.newversion + VersionInfo { } + onShow : { + // Hide information bar with olde version + if ( infoBar.state=="oldVersion" ) { + infoBar.state="upToDate" + dialogVersionInfo.checkVersion = true + } + } + onHide : { + // Reload current version based on online status + if (dialogVersionInfo.checkVersion) go.runCheckVersion(false) + dialogVersionInfo.checkVersion = false + } + } + + DialogYesNo { + id: dialogGlobal + question : "" + answer : "" + z: 100 + } + + PopupEditFolder { + id: popupFolderEdit + anchors { + left: parent.left + right: parent.right + top: infoBar.bottom + bottom: parent.bottom + } + } + + // Popup + PopupMessage { + id: popupMessage + anchors { + left : parent.left + right : parent.right + top : infoBar.bottom + bottom : parent.bottom + } + + onClickedNo: popupMessage.hide() + onClickedOkay: popupMessage.hide() + onClickedYes: { + if (popupMessage.message == gui.areYouSureYouWantToQuit) Qt.quit() + } + } + + // resize + MouseArea { // bottom + id: resizeBottom + property int diff: 0 + anchors { + bottom : parent.bottom + left : parent.left + right : parent.right + } + cursorShape: Qt.SizeVerCursor + height: Style.main.fontSize + onPressed: { + var globPos = mapToGlobal(mouse.x, mouse.y) + resizeBottom.diff = root.height + resizeBottom.diff -= globPos.y + } + onMouseYChanged : { + var globPos = mapToGlobal(mouse.x, mouse.y) + root.height = Math.max(root.minimumHeight, globPos.y + resizeBottom.diff) + } + } + + MouseArea { // right + id: resizeRight + property int diff: 0 + anchors { + top : titleBar.bottom + bottom : parent.bottom + right : parent.right + } + cursorShape: Qt.SizeHorCursor + width: Style.main.fontSize/2 + onPressed: { + var globPos = mapToGlobal(mouse.x, mouse.y) + resizeRight.diff = root.width + resizeRight.diff -= globPos.x + } + onMouseXChanged : { + var globPos = mapToGlobal(mouse.x, mouse.y) + root.width = Math.max(root.minimumWidth, globPos.x + resizeRight.diff) + } + } + + function showAndRise(){ + go.loadAccounts() + root.show() + root.raise() + if (!root.active) { + root.requestActivate() + } + } + + // Toggle window + function toggle() { + go.loadAccounts() + if (root.visible) { + if (!root.active) { + root.raise() + root.requestActivate() + } else { + root.hide() + } + } else { + root.show() + root.raise() + } + } + + onClosing : { + close.accepted=false + if ( + (dialogImport.visible && dialogImport.currentIndex == 4 && go.progress!=1) || + (dialogExport.visible && dialogExport.currentIndex == 2 && go.progress!=1) + ) { + popupMessage.buttonOkay .visible = false + popupMessage.buttonNo .visible = true + popupMessage.buttonYes .visible = true + popupMessage.show ( gui.areYouSureYouWantToQuit ) + return + } + + close.accepted=true + go.processFinished() + } +} diff --git a/internal/frontend/qml/ImportExportUI/OutputFormat.qml b/internal/frontend/qml/ImportExportUI/OutputFormat.qml new file mode 100644 index 00000000..3a87ea27 --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/OutputFormat.qml @@ -0,0 +1,84 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +import QtQuick 2.8 +import QtQuick.Controls 2.2 +import ProtonUI 1.0 +import ImportExportUI 1.0 + +Column { + spacing: Style.dialog.spacing + property string checkedText : group.checkedButton.text + + Text { + id: formatLabel + font { + pointSize: Style.dialog.fontSize * Style.pt + bold: true + } + color: Style.dialog.text + text: qsTr("Select format of exported email:") + + InfoToolTip { + info: qsTr("MBOX exports one file for each folder", "todo") + "\n" + qsTr("EML exports one file for each email", "todo") + anchors { + left: parent.right + leftMargin: Style.dialog.spacing + verticalCenter: parent.verticalCenter + } + } + } + + Row { + spacing : Style.main.leftMargin + ButtonGroup { + id: group + } + + Repeater { + model: [ "MBOX", "EML" ] + delegate : RadioButton { + id: radioDelegate + checked: modelData=="MBOX" + width: 5*Style.dialog.fontSize // hack due to bold + text: modelData + ButtonGroup.group: group + spacing: Style.main.spacing + indicator: Text { + text : radioDelegate.checked ? Style.fa.check_circle : Style.fa.circle_o + color : radioDelegate.checked ? Style.main.textBlue : Style.main.textInactive + font { + pointSize: Style.dialog.iconSize * Style.pt + family: Style.fontawesome.name + } + anchors.verticalCenter: parent.verticalCenter + } + contentItem: Text { + text: radioDelegate.text + color: Style.main.text + font { + pointSize: Style.dialog.fontSize * Style.pt + bold: checked + } + horizontalAlignment : Text.AlignHCenter + verticalAlignment : Text.AlignVCenter + leftPadding: Style.dialog.iconSize + } + } + } + } +} diff --git a/internal/frontend/qml/ImportExportUI/PopupEditFolder.qml b/internal/frontend/qml/ImportExportUI/PopupEditFolder.qml new file mode 100644 index 00000000..7af711c4 --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/PopupEditFolder.qml @@ -0,0 +1,311 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// popup to edit folders or labels +import QtQuick 2.8 +import QtQuick.Controls 2.1 +import ImportExportUI 1.0 +import ProtonUI 1.0 + + +Rectangle { + id: root + visible: false + color: "#aa223344" + + property string folderType : gui.enums.folderTypeFolder + property bool isFolder : folderType == gui.enums.folderTypeFolder + property bool isNew : currentId == "" + property bool isCreateLater : currentId == "createLater" // NOTE: "createLater" is hack because folder id should be base64 string + + property string currentName : "" + property string currentId : "" + property string currentColor : "" + + property string sourceID : "" + property string selectedColor : colorList[0] + + property color textColor : Style.main.background + property color backColor : Style.bubble.paneBackground + + signal edited(string newName, string newColor) + + + + + property var colorList : [ "#7272a7", "#8989ac", "#cf5858", "#cf7e7e", "#c26cc7", "#c793ca", "#7569d1", "#9b94d1", "#69a9d1", "#a8c4d5", "#5ec7b7", "#97c9c1", "#72bb75", "#9db99f", "#c3d261", "#c6cd97", "#e6c04c", "#e7d292", "#e6984c", "#dfb286" ] + + MouseArea { // prevent action below aka modal: true + anchors.fill: parent + hoverEnabled: true + } + + Rectangle { + id:background + + anchors { + fill: root + leftMargin: winMain.width/6 + topMargin: winMain.height/6 + rightMargin: anchors.leftMargin + bottomMargin: anchors.topMargin + } + + color: backColor + radius: Style.errorDialog.radius + } + + + Column { // content + anchors { + top : background.top + horizontalCenter : background.horizontalCenter + } + + topPadding : Style.main.topMargin + bottomPadding : topPadding + spacing : (background.height - title.height - inputField.height - view.height - buttonRow.height - topPadding - bottomPadding) / children.length + + Text { + id: title + + font.pointSize: Style.dialog.titleSize * Style.pt + color: textColor + + text: { + if ( root.isFolder && root.isNew ) return qsTr ( "Create new folder" ) + if ( !root.isFolder && root.isNew ) return qsTr ( "Create new label" ) + if ( root.isFolder && !root.isNew ) return qsTr ( "Edit folder %1" ) .arg( root.currentName ) + if ( !root.isFolder && !root.isNew ) return qsTr ( "Edit label %1" ) .arg( root.currentName ) + } + + width : parent.width + elide : Text.ElideRight + + horizontalAlignment : Text.AlignHCenter + + Rectangle { + anchors { + top: parent.bottom + topMargin: Style.dialog.spacing + horizontalCenter: parent.horizontalCenter + } + color: textColor + height: Style.main.borderInput + } + } + + TextField { + id: inputField + + anchors { + horizontalCenter: parent.horizontalCenter + } + + width : parent.width + height : Style.dialog.button + rightPadding : Style.dialog.spacing + leftPadding : height + rightPadding + bottomPadding : rightPadding + topPadding : rightPadding + selectByMouse : true + color : textColor + font.pointSize : Style.dialog.fontSize * Style.pt + + background: Rectangle { + color: backColor + border { + color: textColor + width: Style.dialog.borderInput + } + + radius : Style.dialog.radiusButton + + Text { + anchors { + left: parent.left + verticalCenter: parent.verticalCenter + } + + font { + family: Style.fontawesome.name + pointSize: Style.dialog.titleSize * Style.pt + } + + text : folderType == gui.enums.folderTypeFolder ? Style.fa.folder : Style.fa.tag + color : root.selectedColor + width : parent.height + horizontalAlignment: Text.AlignHCenter + } + + Rectangle { + anchors { + left: parent.left + top: parent.top + leftMargin: parent.height + } + width: parent.border.width/2 + height: parent.height + } + } + } + + + GridView { + id: view + + anchors { + horizontalCenter: parent.horizontalCenter + } + + model : colorList + cellWidth : 2*Style.dialog.titleSize + cellHeight : cellWidth + width : 10*cellWidth + height : 2*cellHeight + + delegate: Rectangle { + width: view.cellWidth*0.8 + height: width + radius: width/2 + color: modelData + + border { + color: indicator.visible ? textColor : modelData + width: 2*Style.px + } + + Text { + id: indicator + anchors.centerIn : parent + text: Style.fa.check + color: textColor + font { + family: Style.fontawesome.name + pointSize: Style.dialog.titleSize * Style.pt + } + visible: modelData == root.selectedColor + } + + MouseArea { + anchors.fill: parent + onClicked : { + root.selectedColor = modelData + } + } + } + } + + Row { + id: buttonRow + + anchors { + horizontalCenter: parent.horizontalCenter + } + + spacing: Style.main.leftMargin + + ButtonRounded { + text: "Cancel" + color_main : textColor + onClicked :{ + root.hide() + } + } + + ButtonRounded { + text: "Okay" + color_main: Style.dialog.background + color_minor: Style.dialog.textBlue + isOpaque: true + onClicked :{ + root.okay() + } + } + } + } + + function hide() { + root.visible=false + root.currentId = "" + root.currentName = "" + root.currentColor = "" + root.folderType = "" + root.sourceID = "" + inputField.text = "" + } + + function show(currentName, currentId, currentColor, folderType, sourceID) { + root.currentId = currentId + root.currentName = currentName + root.currentColor = currentColor=="" ? go.leastUsedColor() : currentColor + root.selectedColor = root.currentColor + root.folderType = folderType + root.sourceID = sourceID + + inputField.text = currentName + root.visible=true + //console.log(title.text , root.currentName, root.currentId, root.currentColor, root.folderType, root.sourceID) + } + + function okay() { + // check inpupts + if (inputField.text == "") { + go.notifyError(gui.enums.errFillFolderName) + return + } + if (colorList.indexOf(root.selectedColor)<0) { + go.notifyError(gui.enums.errSelectFolderColor) + return + } + var isLabel = root.folderType == gui.enums.folderTypeLabel + if (!isLabel && !root.isFolder){ + console.log("Unknown folder type: ", root.folderType) + go.notifyError(gui.enums.errUpdateLabelFailed) + root.hide() + return + } + + if (winMain.dialogImport.address == "") { + console.log("Unknown address", winMain.dialogImport.address) + go.onNotifyError(gui.enums.errUpdateLabelFailed) + root.hide() + } + + if (root.isCreateLater) { + root.edited(inputField.text, root.selectedColor) + root.hide() + return + } + + + // TODO send request (as timer) + if (root.isNew) { + var isOK = go.createLabelOrFolder(winMain.dialogImport.address, inputField.text, root.selectedColor, isLabel, root.sourceID) + if (isOK) { + root.hide() + } + } else { + // TODO: check there was some change + go.updateLabelOrFolder(winMain.dialogImport.address, root.currentId, inputField.text, root.selectedColor) + } + + // waiting for finish + // TODO: waiting wheel of doom + // TODO: on close add source to sourceID + } +} diff --git a/internal/frontend/qml/ImportExportUI/SelectFolderMenu.qml b/internal/frontend/qml/ImportExportUI/SelectFolderMenu.qml new file mode 100644 index 00000000..5476aef4 --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/SelectFolderMenu.qml @@ -0,0 +1,362 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// This is global combo box which can be adjusted to choose folder target, folder label or global label +import QtQuick 2.8 +import QtQuick.Controls 2.2 +import ProtonUI 1.0 +import ImportExportUI 1.0 + +ComboBox { + id: root + //fixme rounded + height: Style.main.fontSize*2 //fixme + property string folderType: gui.enums.folderTypeFolder + property string selectedIDs + property string sourceID + property bool isFolderType: root.folderType == gui.enums.folderTypeFolder + property bool hasTarget: root.selectedIDs != "" + property bool below: true + + signal doNotImport() + signal importToFolder(string newTargetID) + + leftPadding: Style.dialog.spacing + + onDownChanged : { + if (root.down) view.model.updateFilter() + root.below = popup.y>0 + } + + contentItem : Text { + id: boxText + verticalAlignment: Text.AlignVCenter + font { + family: Style.fontawesome.name + pointSize : Style.dialog.fontSize * Style.pt + bold: root.down + } + elide: Text.ElideRight + textFormat: Text.StyledText + + text : root.displayText + color: !root.enabled ? Style.main.textDisabled : ( root.down ? Style.main.background : Style.main.text ) + } + + displayText: { + //console.trace() + //console.log("updatebox", view.currentIndex, root.hasTarget, root.selectedIDs, root.sourceID, root.folderType) + if (!root.hasTarget) { + if (root.isFolderType) return qsTr("Do not import") + return qsTr("No labels selected") + } + if (!root.isFolderType) return Style.fa.tags + " " + qsTr("Add/Remove labels") + + // We know here that it has a target and this is folder dropdown so we must find the first folder + var selSplit = root.selectedIDs.split(";") + for (var selIndex in selSplit) { + var selectedID = selSplit[selIndex] + var selectedType = structurePM.getType(selectedID) + if (selectedType == gui.enums.folderTypeLabel) continue; // skip type::labele + var selectedName = structurePM.getName(selectedID) + if (selectedName == "") continue; // empty name seems like wrong ID + var icon = gui.folderIcon(selectedName, selectedType) + if (selectedType == gui.enums.folderTypeSystem) { + return icon + " " + selectedName + } + var iconColor = structurePM.getColor(selectedID) + return ''+ icon + " " + selectedName + } + return "" + } + + + background : RoundedRectangle { + fillColor : root.down ? Style.main.textBlue : Style.transparent + strokeColor : root.down ? fillColor : Style.main.line + radiusTopLeft : root.down && !root.below ? 0 : Style.dialog.radiusButton + radiusBottomLeft : root.down && root.below ? 0 : Style.dialog.radiusButton + radiusTopRight : radiusTopLeft + radiusBottomRight : radiusBottomLeft + + MouseArea { + anchors.fill: parent + onClicked : { + if (root.down) root.popup.close() + else root.popup.open() + } + } + } + + indicator : Text { + text: (root.down && root.below) || (!root.down && !root.below) ? Style.fa.chevron_up : Style.fa.chevron_down + anchors { + right: parent.right + verticalCenter: parent.verticalCenter + rightMargin: Style.dialog.spacing + } + font { + family : Style.fontawesome.name + pointSize : Style.dialog.fontSize * Style.pt + } + color: root.enabled && !root.down ? Style.main.textBlue : root.contentItem.color + } + + // Popup objects + delegate: Rectangle { + id: thisDelegate + + height : Style.main.fontSize * 2 + width : selectNone.width + + property bool isHovered: area.containsMouse + + color: isHovered ? root.popup.hoverColor : root.popup.backColor + + + property bool isSelected : { + var selected = root.selectedIDs.split(";") + for (var iSel in selected) { + var sel = selected[iSel] + if (folderId == sel){ + return true + } + } + return false + } + + Text { + id: targetIcon + text: gui.folderIcon(folderName,folderType) + color : folderType != gui.enums.folderTypeSystem ? folderColor : root.popup.textColor + anchors { + verticalCenter: parent.verticalCenter + left: parent.left + leftMargin: root.leftPadding + } + font { + family : Style.fontawesome.name + pointSize : Style.dialog.fontSize * Style.pt + } + } + + Text { + id: targetName + + anchors { + verticalCenter: parent.verticalCenter + left: targetIcon.right + right: parent.right + leftMargin: Style.dialog.spacing + rightMargin: Style.dialog.spacing + } + + text: folderName + color : root.popup.textColor + elide: Text.ElideRight + + font { + family : Style.fontawesome.name + pointSize : Style.dialog.fontSize * Style.pt + } + } + + Text { + id: targetIndicator + anchors { + right: parent.right + verticalCenter: parent.verticalCenter + } + + text : thisDelegate.isSelected ? Style.fa.check_square : Style.fa.square_o + visible : thisDelegate.isSelected || !root.isFolderType + color : root.popup.textColor + font { + family : Style.fontawesome.name + pointSize : Style.dialog.fontSize * Style.pt + } + } + + Rectangle { + id: line + anchors { + bottom : parent.bottom + left : parent.left + right : parent.right + } + height : Style.main.lineWidth + color : Style.main.line + } + + MouseArea { + id: area + anchors.fill: parent + + onClicked: { + //console.log(" click delegate") + if (root.isFolderType) { // don't update if selected + if (!thisDelegate.isSelected) { + root.importToFolder(folderId) + } + root.popup.close() + } + if (root.folderType==gui.enums.folderTypeLabel) { + if (thisDelegate.isSelected) { + structureExternal.removeTargetLabelID(sourceID,folderId) + } else { + structureExternal.addTargetLabelID(sourceID,folderId) + } + } + } + hoverEnabled: true + } + } + + popup : Popup { + y: root.height + width: root.width + modal: true + closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape + padding: Style.dialog.spacing + + property var textColor : Style.main.background + property var backColor : Style.main.text + property var hoverColor : Style.main.textBlue + + contentItem : Column { + // header + Rectangle { + id: selectNone + width: root.popup.width - 2*root.popup.padding + //height: root.isFolderType ? 2* Style.main.fontSize : 0 + height: 2*Style.main.fontSize + color: area.containsMouse ? root.popup.hoverColor : root.popup.backColor + visible : root.isFolderType + + Text { + anchors { + left : parent.left + leftMargin : Style.dialog.spacing + verticalCenter : parent.verticalCenter + } + text: root.isFolderType ? qsTr("Do not import") : "" + color: root.popup.textColor + font { + pointSize: Style.dialog.fontSize * Style.pt + bold: true + } + } + + Rectangle { + id: line + anchors { + bottom : parent.bottom + left : parent.left + right : parent.right + } + height : Style.dialog.borderInput + color : Style.main.line + } + + MouseArea { + id: area + anchors.fill: parent + onClicked: { + //console.log(" click no set") + root.doNotImport() + root.popup.close() + } + hoverEnabled: true + } + } + + // scroll area + Rectangle { + width: selectNone.width + height: winMain.height/4 + color: root.popup.backColor + + ListView { + id: view + + clip : true + anchors.fill : parent + + section.property : "sectionName" + section.delegate : Text{text: sectionName} + + model : FilterStructure { + filterOnGroup : root.folderType + delegate : root.delegate + } + } + } + + // footer + Rectangle { + id: addFolderOrLabel + width: selectNone.width + height: addButton.height + 3*Style.dialog.spacing + color: root.popup.backColor + + Rectangle { + anchors { + top : parent.top + left : parent.left + right : parent.right + } + height : Style.dialog.borderInput + color : Style.main.line + } + + ButtonRounded { + id: addButton + anchors.centerIn: addFolderOrLabel + width: parent.width * 0.681 + + fa_icon : Style.fa.plus_circle + text : root.isFolderType ? qsTr("Create new folder") : qsTr("Create new label") + color_main : root.popup.textColor + } + + MouseArea { + anchors.fill : parent + + onClicked : { + //console.log("click", addButton.text) + var newName = "" + if ( typeof folderName !== 'undefined' && !structurePM.hasFolderWithName (folderName) ) { + newName = folderName + } + winMain.popupFolderEdit.show(newName, "", "", root.folderType, sourceID) + root.popup.close() + } + } + } + } + + background : RoundedRectangle { + strokeColor : root.popup.backColor + fillColor : root.popup.backColor + radiusTopLeft : root.below ? 0 : Style.dialog.radiusButton + radiusBottomLeft : !root.below ? 0 : Style.dialog.radiusButton + radiusTopRight : radiusTopLeft + radiusBottomRight : radiusBottomLeft + } + } +} + diff --git a/internal/frontend/qml/ImportExportUI/SelectLabelsMenu.qml b/internal/frontend/qml/ImportExportUI/SelectLabelsMenu.qml new file mode 100644 index 00000000..dec1d2d7 --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/SelectLabelsMenu.qml @@ -0,0 +1,29 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// List of import folder and their target +import QtQuick 2.8 +import QtQuick.Controls 2.2 +import ProtonUI 1.0 +import ImportExportUI 1.0 + +SelectFolderMenu { + id: root + folderType: gui.enums.folderTypeLabel +} + + diff --git a/internal/frontend/qml/ImportExportUI/SettingsView.qml b/internal/frontend/qml/ImportExportUI/SettingsView.qml new file mode 100644 index 00000000..ced1887e --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/SettingsView.qml @@ -0,0 +1,148 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// List the settings + +import QtQuick 2.8 +import ProtonUI 1.0 +import ImportExportUI 1.0 + +Item { + id: root + + // must have wrapper + Rectangle { + id: wrapper + anchors.centerIn: parent + width: parent.width + height: parent.height + color: Style.main.background + + // content + Column { + anchors.left : parent.left + + ButtonIconText { + id: cacheKeychain + text: qsTr("Clear Keychain") + leftIcon.text : Style.fa.chain_broken + rightIcon { + text : qsTr("Clear") + color: Style.main.text + font { + pointSize : Style.settings.fontSize * Style.pt + underline : true + } + } + onClicked: { + dialogGlobal.state="clearChain" + dialogGlobal.show() + } + } + + ButtonIconText { + id: logs + anchors.left: parent.left + text: qsTr("Logs") + leftIcon.text : Style.fa.align_justify + rightIcon.text : Style.fa.chevron_circle_right + rightIcon.font.pointSize : Style.settings.toggleSize * Style.pt + onClicked: go.openLogs() + } + + ButtonIconText { + id: bugreport + anchors.left: parent.left + text: qsTr("Report Bug") + leftIcon.text : Style.fa.bug + rightIcon.text : Style.fa.chevron_circle_right + rightIcon.font.pointSize : Style.settings.toggleSize * Style.pt + onClicked: bugreportWin.show() + } + + /* + + ButtonIconText { + id: cacheClear + text: qsTr("Clear Cache") + leftIcon.text : Style.fa.times + rightIcon { + text : qsTr("Clear") + color: Style.main.text + font { + pointSize : Style.settings.fontSize * Style.pt + underline : true + } + } + onClicked: { + dialogGlobal.state="clearCache" + dialogGlobal.show() + } + } + + + ButtonIconText { + id: autoStart + text: qsTr("Automatically Start Bridge") + leftIcon.text : Style.fa.rocket + rightIcon { + font.pointSize : Style.settings.toggleSize * Style.pt + text : go.isAutoStart!=0 ? Style.fa.toggle_on : Style.fa.toggle_off + color : go.isAutoStart!=0 ? Style.main.textBlue : Style.main.textDisabled + } + onClicked: { + go.toggleAutoStart() + } + } + + ButtonIconText { + id: advancedSettings + property bool isAdvanced : !go.isDefaultPort + text: qsTr("Advanced settings") + leftIcon.text : Style.fa.cogs + rightIcon { + font.pointSize : Style.settings.toggleSize * Style.pt + text : isAdvanced!=0 ? Style.fa.chevron_circle_up : Style.fa.chevron_circle_right + color : isAdvanced!=0 ? Style.main.textDisabled : Style.main.textBlue + } + onClicked: { + isAdvanced = !isAdvanced + } + } + + ButtonIconText { + id: changePort + visible: advancedSettings.isAdvanced + text: qsTr("Change SMTP/IMAP Ports") + leftIcon.text : Style.fa.plug + rightIcon { + text : qsTr("Change") + color: Style.main.text + font { + pointSize : Style.settings.fontSize * Style.pt + underline : true + } + } + onClicked: { + dialogChangePort.show() + } + } + */ + } + } +} + diff --git a/internal/frontend/qml/ImportExportUI/VersionInfo.qml b/internal/frontend/qml/ImportExportUI/VersionInfo.qml new file mode 100644 index 00000000..ad90488d --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/VersionInfo.qml @@ -0,0 +1,115 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// credits + +import QtQuick 2.8 +import ProtonUI 1.0 +import ImportExportUI 1.0 + +Item { + id: root + Rectangle { + id: wrapper + anchors.centerIn: parent + width: 2*Style.main.width/3 + height: Style.main.height - 6*Style.dialog.titleSize + color: "transparent" + + Flickable { + anchors.fill : wrapper + contentWidth : wrapper.width + contentHeight : content.height + flickableDirection : Flickable.VerticalFlick + clip : true + + + Column { + id: content + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + width: wrapper.width + spacing: 5 + + Text { + visible: go.changelog != "" + anchors { + left: parent.left + } + font.bold: true + font.pointSize: Style.main.fontSize * Style.pt + color: Style.main.text + text: qsTr("Release notes:") + } + + Text { + anchors { + left: parent.left + leftMargin: Style.main.leftMargin + } + font.pointSize: Style.main.fontSize * Style.pt + width: wrapper.width - anchors.leftMargin + wrapMode: Text.Wrap + color: Style.main.text + text: go.changelog + } + + Text { + visible: go.bugfixes != "" + anchors { + left: parent.left + } + font.bold: true + font.pointSize: Style.main.fontSize * Style.pt + color: Style.main.text + text: qsTr("Fixed bugs:") + } + + Repeater { + anchors.fill: parent + model: go.bugfixes.split(";") + + Text { + visible: go.bugfixes!="" + anchors { + left: parent.left + leftMargin: Style.main.leftMargin + } + font.pointSize: Style.main.fontSize * Style.pt + width: wrapper.width - anchors.leftMargin + wrapMode: Text.Wrap + color: Style.main.text + text: modelData + } + } + + Rectangle{id:spacer; color:"transparent"; width:10; height: buttonClose.height} + + + ButtonRounded { + id: buttonClose + anchors.horizontalCenter: content.horizontalCenter + text: "Close" + onClicked: { + root.parent.hide() + } + } + } + } + } +} + diff --git a/internal/frontend/qml/ImportExportUI/qmldir b/internal/frontend/qml/ImportExportUI/qmldir new file mode 100644 index 00000000..752ffc16 --- /dev/null +++ b/internal/frontend/qml/ImportExportUI/qmldir @@ -0,0 +1,31 @@ +module ImportExportUI +AccountDelegate 1.0 AccountDelegate.qml +Credits 1.0 Credits.qml +DateBox 1.0 DateBox.qml +DateInput 1.0 DateInput.qml +DateRangeMenu 1.0 DateRangeMenu.qml +DateRange 1.0 DateRange.qml +DateRangeFunctions 1.0 DateRangeFunctions.qml +DialogExport 1.0 DialogExport.qml +DialogImport 1.0 DialogImport.qml +DialogYesNo 1.0 DialogYesNo.qml +ExportStructure 1.0 ExportStructure.qml +FilterStructure 1.0 FilterStructure.qml +FolderRowButton 1.0 FolderRowButton.qml +HelpView 1.0 HelpView.qml +IEStyle 1.0 IEStyle.qml +ImportDelegate 1.0 ImportDelegate.qml +ImportSourceButton 1.0 ImportSourceButton.qml +ImportStructure 1.0 ImportStructure.qml +ImportReport 1.0 ImportReport.qml +ImportReportCell 1.0 ImportReportCell.qml +InlineDateRange 1.0 InlineDateRange.qml +InlineLabelSelect 1.0 InlineLabelSelect.qml +LabelIconList 1.0 LabelIconList.qml +MainWindow 1.0 MainWindow.qml +OutputFormat 1.0 OutputFormat.qml +PopupEditFolder 1.0 PopupEditFolder.qml +SelectFolderMenu 1.0 SelectFolderMenu.qml +SelectLabelsMenu 1.0 SelectLabelsMenu.qml +SettingsView 1.0 SettingsView.qml +VersionInfo 1.0 VersionInfo.qml diff --git a/internal/frontend/qml/ProtonUI/AccountView.qml b/internal/frontend/qml/ProtonUI/AccountView.qml index 13a437a0..72094fe3 100644 --- a/internal/frontend/qml/ProtonUI/AccountView.qml +++ b/internal/frontend/qml/ProtonUI/AccountView.qml @@ -87,7 +87,7 @@ Item { Text { // Status anchors { left : parent.left - leftMargin : Style.accounts.leftMargin2 + leftMargin : viewContent.width/2 verticalCenter : parent.verticalCenter } visible: root.numAccounts!=0 @@ -99,7 +99,7 @@ Item { Text { // Actions anchors { left : parent.left - leftMargin : Style.accounts.leftMargin3 + leftMargin : 5.5*viewContent.width/8 verticalCenter : parent.verticalCenter } visible: root.numAccounts!=0 diff --git a/internal/frontend/qml/ProtonUI/BugReportWindow.qml b/internal/frontend/qml/ProtonUI/BugReportWindow.qml index e8857179..9a6ccfde 100644 --- a/internal/frontend/qml/ProtonUI/BugReportWindow.qml +++ b/internal/frontend/qml/ProtonUI/BugReportWindow.qml @@ -327,6 +327,7 @@ Window { function show() { prefill() + description.focus=true root.visible=true } diff --git a/internal/frontend/qml/ProtonUI/CheckBoxLabel.qml b/internal/frontend/qml/ProtonUI/CheckBoxLabel.qml index abfd800b..4746f9b8 100644 --- a/internal/frontend/qml/ProtonUI/CheckBoxLabel.qml +++ b/internal/frontend/qml/ProtonUI/CheckBoxLabel.qml @@ -30,10 +30,12 @@ CheckBox { property color uncheckedColor : Style.main.textInactive property string checkedSymbol : Style.fa.check_square_o property string uncheckedSymbol : Style.fa.square_o + property alias symbolPointSize : symbol.font.pointSize background: Rectangle { color: Style.transparent } indicator: Text { + id: symbol text : root.checked ? root.checkedSymbol : root.uncheckedSymbol color : root.checked ? root.checkedColor : root.uncheckedColor font { diff --git a/internal/frontend/qml/ProtonUI/DialogUpdate.qml b/internal/frontend/qml/ProtonUI/DialogUpdate.qml index 479de5f1..261c35f7 100644 --- a/internal/frontend/qml/ProtonUI/DialogUpdate.qml +++ b/internal/frontend/qml/ProtonUI/DialogUpdate.qml @@ -123,12 +123,12 @@ Dialog { wrapMode: Text.Wrap text: { switch (go.progressDescription) { - case 1: return qsTr("Checking the current version.") - case 2: return qsTr("Downloading the update files.") - case 3: return qsTr("Verifying the update files.") - case 4: return qsTr("Unpacking the update files.") - case 5: return qsTr("Starting the update.") - case 6: return qsTr("Quitting the application.") + case "1": return qsTr("Checking the current version.") + case "2": return qsTr("Downloading the update files.") + case "3": return qsTr("Verifying the update files.") + case "4": return qsTr("Unpacking the update files.") + case "5": return qsTr("Starting the update.") + case "6": return qsTr("Quitting the application.") default: return "" } } @@ -220,7 +220,7 @@ Dialog { function clear() { root.hasError = false go.progress = 0.0 - go.progressDescription = 0 + go.progressDescription = "0" } function finished(hasError) { diff --git a/internal/frontend/qml/ProtonUI/InputField.qml b/internal/frontend/qml/ProtonUI/InputField.qml index 525fad39..3c8db7ca 100644 --- a/internal/frontend/qml/ProtonUI/InputField.qml +++ b/internal/frontend/qml/ProtonUI/InputField.qml @@ -138,6 +138,11 @@ Column { } } + function clear() { + inputField.text = "" + rightIcon = "" + } + function checkNonEmpty() { if (inputField.text == "") { rightIcon = Style.fa.exclamation_triangle @@ -154,6 +159,17 @@ Column { if (root.isPassword) inputField.echoMode = TextInput.Password } + function checkIsANumber(){ + if (/^\d+$/.test(inputField.text)) { + rightIcon = Style.fa.check_circle + return true + } + rightIcon = Style.fa.exclamation_triangle + root.placeholderText = "" + inputField.focus = true + return false + } + function forceFocus() { inputField.forceActiveFocus() } diff --git a/internal/frontend/qml/ProtonUI/PopupMessage.qml b/internal/frontend/qml/ProtonUI/PopupMessage.qml index 67aa1b36..5c2f32ae 100644 --- a/internal/frontend/qml/ProtonUI/PopupMessage.qml +++ b/internal/frontend/qml/ProtonUI/PopupMessage.qml @@ -23,9 +23,25 @@ import ProtonUI 1.0 Rectangle { id: root color: Style.transparent - property alias text: message.text + property alias text : message.text + property alias checkbox : checkbox + property alias buttonOkay : buttonOkay + property alias buttonYes : buttonYes + property alias buttonNo : buttonNo + property alias buttonRetry : buttonRetry + property alias buttonSkip : buttonSkip + property alias buttonCancel : buttonCancel + property alias msgWidth : backgroundInp.width + property string msgID : "" visible: false + signal clickedOkay() + signal clickedYes() + signal clickedNo() + signal clickedRetry() + signal clickedSkip() + signal clickedCancel() + MouseArea { // prevent action below anchors.fill: parent hoverEnabled: true @@ -58,14 +74,29 @@ Rectangle { wrapMode: Text.Wrap } - ButtonRounded { - text : qsTr("Okay", "todo") - isOpaque : true - color_main : Style.dialog.background - color_minor : Style.dialog.textBlue - onClicked : root.hide() + CheckBoxLabel { + id: checkbox + text: "" + checked: false + visible: (text != "") + textColor : Style.errorDialog.text + checkedColor: Style.errorDialog.text + uncheckedColor: Style.errorDialog.text anchors.horizontalCenter : parent.horizontalCenter } + + Row { + spacing: Style.dialog.spacing + anchors.horizontalCenter : parent.horizontalCenter + + ButtonRounded { id : buttonNo ; text : qsTr ( "No" , "Button No" ) ; onClicked : root.clickedNo ( ) ; visible : false ; isOpaque : false ; color_main : Style.errorDialog.text ; color_minor : Style.transparent ; } + ButtonRounded { id : buttonYes ; text : qsTr ( "Yes" , "Button Yes" ) ; onClicked : root.clickedYes ( ) ; visible : false ; isOpaque : true ; color_main : Style.errorDialog.text ; color_minor : Style.dialog.textBlue ; } + ButtonRounded { id : buttonRetry ; text : qsTr ( "Retry" , "Button Retry" ) ; onClicked : root.clickedRetry ( ) ; visible : false ; isOpaque : false ; color_main : Style.errorDialog.text ; color_minor : Style.transparent ; } + ButtonRounded { id : buttonSkip ; text : qsTr ( "Skip" , "Button Skip" ) ; onClicked : root.clickedSkip ( ) ; visible : false ; isOpaque : false ; color_main : Style.errorDialog.text ; color_minor : Style.transparent ; } + ButtonRounded { id : buttonCancel ; text : qsTr ( "Cancel" , "Button Cancel" ) ; onClicked : root.clickedCancel ( ) ; visible : false ; isOpaque : true ; color_main : Style.errorDialog.text ; color_minor : Style.dialog.textBlue ; } + ButtonRounded { id : buttonOkay ; text : qsTr ( "Okay" , "Button Okay" ) ; onClicked : root.clickedOkay ( ) ; visible : true ; isOpaque : true ; color_main : Style.errorDialog.text ; color_minor : Style.dialog.textBlue ; } + + } } } @@ -75,7 +106,16 @@ Rectangle { } function hide() { - root.state = "Okay" root.visible=false + + root .text = "" + checkbox .text = "" + + buttonNo .visible = false + buttonYes .visible = false + buttonRetry .visible = false + buttonSkip .visible = false + buttonCancel .visible = false + buttonOkay .visible = true } } diff --git a/internal/frontend/qml/ProtonUI/RoundedRectangle.qml b/internal/frontend/qml/ProtonUI/RoundedRectangle.qml new file mode 100644 index 00000000..4b63de09 --- /dev/null +++ b/internal/frontend/qml/ProtonUI/RoundedRectangle.qml @@ -0,0 +1,115 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +import QtQuick 2.8 +import ProtonUI 1.0 + + +Rectangle { + id: root + + color: Style.transparent + + property color fillColor : Style.main.background + property color strokeColor : Style.main.line + property real strokeWidth : Style.dialog.borderInput + property real radiusTopLeft : Style.dialog.radiusButton + property real radiusBottomLeft : Style.dialog.radiusButton + property real radiusTopRight : Style.dialog.radiusButton + property real radiusBottomRight : Style.dialog.radiusButton + + function paint() { + canvas.requestPaint() + } + + onFillColorChanged : root.paint() + onStrokeColorChanged : root.paint() + onStrokeWidthChanged : root.paint() + onRadiusTopLeftChanged : root.paint() + onRadiusBottomLeftChanged : root.paint() + onRadiusTopRightChanged : root.paint() + onRadiusBottomRightChanged : root.paint() + + + Canvas { + id: canvas + anchors.fill: root + + onPaint: { + var ctx = getContext("2d") + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = root.fillColor + ctx.strokeStyle = root.strokeColor + ctx.lineWidth = root.strokeWidth + var dimensions = { + x: ctx.lineWidth, + y: ctx.lineWidth, + w: canvas.width-2*ctx.lineWidth, + h: canvas.height-2*ctx.lineWidth, + } + var radius = { + tl: root.radiusTopLeft, + tr: root.radiusTopRight, + bl: root.radiusBottomLeft, + br: root.radiusBottomRight, + } + + root.roundRect( + ctx, + dimensions, + radius, true, true + ) + } + } + + // adapted from: https://stackoverflow.com/questions/1255512/how-to-draw-a-rounded-rectangle-on-html-canvas/3368118#3368118 + function roundRect(ctx, dim, radius, fill, stroke) { + if (typeof stroke == 'undefined') { + stroke = true; + } + if (typeof radius === 'undefined') { + radius = 5; + } + if (typeof radius === 'number') { + radius = {tl: radius, tr: radius, br: radius, bl: radius}; + } else { + var defaultRadius = {tl: 0, tr: 0, br: 0, bl: 0}; + for (var side in defaultRadius) { + radius[side] = radius[side] || defaultRadius[side]; + } + } + ctx.beginPath(); + ctx.moveTo(dim.x + radius.tl, dim.y); + ctx.lineTo(dim.x + dim.w - radius.tr, dim.y); + ctx.quadraticCurveTo(dim.x + dim.w, dim.y, dim.x + dim.w, dim.y + radius.tr); + ctx.lineTo(dim.x + dim.w, dim.y + dim.h - radius.br); + ctx.quadraticCurveTo(dim.x + dim.w, dim.y + dim.h, dim.x + dim.w - radius.br, dim.y + dim.h); + ctx.lineTo(dim.x + radius.bl, dim.y + dim.h); + ctx.quadraticCurveTo(dim.x, dim.y + dim.h, dim.x, dim.y + dim.h - radius.bl); + ctx.lineTo(dim.x, dim.y + radius.tl); + ctx.quadraticCurveTo(dim.x, dim.y, dim.x + radius.tl, dim.y); + ctx.closePath(); + if (fill) { + ctx.fill(); + } + if (stroke) { + ctx.stroke(); + } + } + + Component.onCompleted: root.paint() +} diff --git a/internal/frontend/qml/ProtonUI/Style.qml b/internal/frontend/qml/ProtonUI/Style.qml index 07655ba1..e7979854 100644 --- a/internal/frontend/qml/ProtonUI/Style.qml +++ b/internal/frontend/qml/ProtonUI/Style.qml @@ -221,6 +221,31 @@ QtObject { property real leftMargin3 : 30 * px } + property QtObject importing : QtObject { + property color rowBackground : dialog.background + property color rowLine : dialog.line + } + + property QtObject dropDownLight: QtObject { + property color background : dialog.background + property color text : dialog.text + property color inactive : dialog.line + property color highlight : dialog.textBlue + property color separator : dialog.line + property color line : dialog.line + property bool labelBold : true + } + + property QtObject dropDownDark : QtObject { + property color background : dialog.text + property color text : dialog.background + property color inactive : dialog.line + property color highlight : dialog.textBlue + property color separator : dialog.line + property color line : dialog.line + property bool labelBold : true + } + property int okInfoBar : 0 property int warnInfoBar : 1 property int warnBubbleMessage : 2 diff --git a/internal/frontend/qml/ProtonUI/WindowTitleBar.qml b/internal/frontend/qml/ProtonUI/WindowTitleBar.qml index c48efafc..8234a521 100644 --- a/internal/frontend/qml/ProtonUI/WindowTitleBar.qml +++ b/internal/frontend/qml/ProtonUI/WindowTitleBar.qml @@ -23,7 +23,9 @@ import ProtonUI 1.0 Rectangle { id: root - height: root.isDarwin ? Style.titleMacOS.height : Style.title.height + height: visible ? ( + root.isDarwin ? Style.titleMacOS.height : Style.title.height + ) : 0 color: "transparent" property bool isDarwin : (go.goos == "darwin") property QtObject window diff --git a/internal/frontend/qml/ProtonUI/qmldir b/internal/frontend/qml/ProtonUI/qmldir index afe0b94a..ade9e7aa 100644 --- a/internal/frontend/qml/ProtonUI/qmldir +++ b/internal/frontend/qml/ProtonUI/qmldir @@ -23,6 +23,7 @@ InputField 1.0 InputField.qml InstanceExistsWindow 1.0 InstanceExistsWindow.qml LogoHeader 1.0 LogoHeader.qml PopupMessage 1.0 PopupMessage.qml +RoundedRectangle 1.0 RoundedRectangle.qml TabButton 1.0 TabButton.qml TabLabels 1.0 TabLabels.qml TextLabel 1.0 TextLabel.qml diff --git a/internal/frontend/qml/tst_GuiIE.qml b/internal/frontend/qml/tst_GuiIE.qml new file mode 100644 index 00000000..625edda0 --- /dev/null +++ b/internal/frontend/qml/tst_GuiIE.qml @@ -0,0 +1,970 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +import QtQuick 2.8 +import ImportExportUI 1.0 +import ProtonUI 1.0 +import QtQuick.Controls 2.1 +import QtQuick.Window 2.2 + +Window { + id : testroot + width : 100 + height : 600 + flags : Qt.Window | Qt.Dialog | Qt.FramelessWindowHint + visible : true + title : "GUI test Window" + color : "transparent" + x : testgui.winMain.x - 120 + y : testgui.winMain.y + + property bool newVersion : true + + Accessible.name: testroot.title + Accessible.description: "Window with buttons testing the GUI events" + + + Rectangle { + id:test_systray + anchors{ + top: parent.top + horizontalCenter: parent.horizontalCenter + } + height: 40 + width: 100 + color: "yellow" + Image { + id: sysImg + anchors { + left : test_systray.left + top : test_systray.top + } + height: test_systray.height + mipmap: true + fillMode : Image.PreserveAspectFit + source: "" + } + Text { + id: systrText + anchors { + right : test_systray.right + verticalCenter: test_systray.verticalCenter + } + text: "unset" + } + + function normal() { + test_systray.color = "#22ee22" + systrText.text = "norm" + sysImg.source= "../share/icons/rounded-systray.png" + } + function highlight() { + test_systray.color = "#eeee22" + systrText.text = "highl" + sysImg.source= "../share/icons/rounded-syswarn.png" + } + + MouseArea { + property point diff: "0,0" + anchors.fill: parent + onPressed: { + diff = Qt.point(testroot.x, testroot.y) + var mousePos = mapToGlobal(mouse.x, mouse.y) + diff.x -= mousePos.x + diff.y -= mousePos.y + } + onPositionChanged: { + var currPos = mapToGlobal(mouse.x, mouse.y) + testroot.x = currPos.x + diff.x + testroot.y = currPos.y + diff.y + } + } + } + + ListModel { + id: buttons + + ListElement { title : "Show window" } + ListElement { title : "Logout cuthix" } + ListElement { title : "Internet on" } + ListElement { title : "Internet off" } + ListElement { title : "Macos" } + ListElement { title : "Windows" } + ListElement { title : "Linux" } + ListElement { title : "New Version" } + ListElement { title : "ForceUpgrade" } + ListElement { title : "ImportStructure" } + ListElement { title : "DraftImpFailed" } + ListElement { title : "NoInterImp" } + ListElement { title : "ReportImp" } + ListElement { title : "NewFolder" } + ListElement { title : "EditFolder" } + ListElement { title : "EditLabel" } + ListElement { title : "ExpProgErr" } + ListElement { title : "ImpProgErr" } + } + + ListView { + id: view + anchors { + top : test_systray.bottom + bottom : parent.bottom + left : parent.left + right : parent.right + } + + orientation : ListView.Vertical + model : buttons + focus : true + + delegate : ButtonRounded { + text : title + color_main : "orange" + color_minor : "#aa335588" + isOpaque : true + anchors.horizontalCenter: parent.horizontalCenter + onClicked : { + console.log("Clicked on ", title) + switch (title) { + case "Show window" : + go.showWindow(); + break; + case "Logout cuthix" : + go.checkLoggedOut("cuthix"); + break; + case "Internet on" : + go.setConnectionStatus(true); + break; + case "Internet off" : + go.setConnectionStatus(false); + break; + case "Macos" : + go.goos = "darwin"; + break; + case "Windows" : + go.goos = "windows"; + break; + case "Linux" : + go.goos = "linux"; + break; + case "New Version" : + testroot.newVersion = !testroot.newVersion + systrText.text = testroot.newVersion ? "new version" : "uptodate" + break + case "ForceUpgrade" : + go.notifyUpgrade() + break; + case "ImportStructure" : + testgui.winMain.dialogImport.address = "cuto@pm.com" + testgui.winMain.dialogImport.show() + testgui.winMain.dialogImport.currentIndex=DialogImport.Page.SourceToTarget + break + case "DraftImpFailed" : + testgui.notifyError(testgui.enums.errDraftImportFailed) + break + case "NoInterImp" : + testgui.notifyError(testgui.enums.errNoInternetWhileImport) + break + case "ReportImp" : + testgui.winMain.dialogImport.address = "cuto@pm.com" + testgui.winMain.dialogImport.show() + testgui.winMain.dialogImport.currentIndex=DialogImport.Page.Report + break + case "NewFolder" : + testgui.winMain.popupFolderEdit.show("currentName", "", "", testgui.enums.folderTypeFolder, "") + break + case "EditFolder" : + testgui.winMain.popupFolderEdit.show("currentName", "", "#7272a7", testgui.enums.folderTypeFolder, "") + break + case "EditFolder" : + testgui.winMain.popupFolderEdit.show("currentName", "", "", testgui.enums.folderTypeFolder, "") + break + case "ImpProgErr" : + go.animateProgressBar.pause() + testgui.notifyError(testgui.enums.errEmailImportFailed) + break + case "ExpProgErr" : + go.animateProgressBar.pause() + testgui.notifyError(testgui.enums.errEmailExportFailed) + break + default : + console.log("Not implemented " + title) + } + } + } + } + + + Component.onCompleted : { + testgui.winMain.x = 150 + testgui.winMain.y = 100 + } + + //InstanceExistsWindow { id: ie_test } + + GuiIE { + id: testgui + //checkLogTimer.interval: 3*1000 + winMain.visible: true + + ListModel{ + id: accountsModel + ListElement{ account : "cuthix" ; status : "connected"; isExpanded: false; isCombinedAddressMode: false; hostname : "127.0.0.1"; password : "ZI9tKp+ryaxmbpn2E12"; security : "StarTLS"; portSMTP : 1025; portIMAP : 1143; aliases : "cuto@pm.com;jaku@pm.com;DoYouKnowAboutAMovieCalledTheHorriblySlowMurderWithExtremelyInefficientWeapon@thatYouCanFindForExampleOnyoutube.com" } + ListElement{ account : "exteremelongnamewhichmustbeeladedinthemiddleoftheaddress@protonmail.com" ; status : "connected"; isExpanded: true; isCombinedAddressMode: true; hostname : "127.0.0.1"; password : "ZI9tKp+ryaxmbpn2E12"; security : "StarTLS"; portSMTP : 1025; portIMAP : 1143; aliases : "cuto@pm.com;jaku@pm.com;hu@hu.hu" } + ListElement{ account : "cuthix2@protonmail.com" ; status : "disconnected"; isExpanded: false; isCombinedAddressMode: false; hostname : "127.0.0.1"; password : "ZI9tKp+ryaxmbpn2E12"; security : "StarTLS"; portSMTP : 1025; portIMAP : 1143; aliases : "cuto@pm.com;jaku@pm.com;hu@hu.hu" } + ListElement{ account : "many@protonmail.com" ; status : "connected"; isExpanded: true; isCombinedAddressMode: true; hostname : "127.0.0.1"; password : "ZI9tKp+ryaxmbpn2E12"; security : "StarTLS"; portSMTP : 1025; portIMAP : 1143; aliases : "cuto@pm.com;jaku@pm.com;hu@hu.hu;cuto@pm.com;jaku@pm.com;hu@hu.hu;cuto@pm.com;jaku@pm.com;hu@hu.hu;cuto@pm.com;jaku@pm.com;hu@hu.hu;cuto@pm.com;jaku@pm.com;hu@hu.hu;cuto@pm.com;jaku@pm.com;hu@hu.hu;cuto@pm.com;jaku@pm.com;hu@hu.hu;cuto@pm.com;jaku@pm.com;hu@hu.hu;cuto@pm.com;jaku@pm.com;hu@hu.hu;cuto@pm.com;jaku@pm.com;hu@hu.hu;cuto@pm.com;jaku@pm.com;hu@hu.hu;cuto@pm.com;jaku@pm.com;hu@hu.hu;cuto@pm.com;jaku@pm.com;hu@hu.hu;cuto@pm.com;jaku@pm.com;hu@hu.hu;cuto@pm.com;jaku@pm.com;hu@hu.hu;"} + } + + ListModel{ + id: structureExternal + + property var globalOptions: JSON.parse('{ "folderId" : "global--uniq" , "folderName" : "" , "folderColor" : "" , "folderType" : "" , "folderEntries" : 0, "fromDate": 0, "toDate": 0, "isFolderSelected" : false , "targetFolderID": "14" , "targetLabelIDs": ";20;29" }') + + ListElement{ folderId : "Inbox" ; folderName : "Inbox" ; folderColor : "black" ; folderType : "" ; folderEntries : 1 ; fromDate : 0 ; toDate : 0 ; isFolderSelected : true ; targetFolderID : "14" ; targetLabelIDs : ";20;29" } + ListElement{ folderId : "Sent" ; folderName : "Sent" ; folderColor : "black" ; folderType : "" ; folderEntries : 2 ; fromDate : 0 ; toDate : 0 ; isFolderSelected : false ; targetFolderID : "" ; targetLabelIDs : "" } + ListElement{ folderId : "Spam" ; folderName : "Spam" ; folderColor : "black" ; folderType : "" ; folderEntries : 3 ; fromDate : 0 ; toDate : 0 ; isFolderSelected : false ; targetFolderID : "" ; targetLabelIDs : "" } + ListElement{ folderId : "Draft" ; folderName : "Draft" ; folderColor : "black" ; folderType : "" ; folderEntries : 4 ; fromDate : 0 ; toDate : 0 ; isFolderSelected : true ; targetFolderID : "14" ; targetLabelIDs : ";20;29" } + + ListElement{ folderId : "Folder0" ; folderName : "Folder0" ; folderColor : "black" ; folderType : "folder" ; folderEntries : 10 ; fromDate : 300000 ; toDate : 15000000 ; isFolderSelected : true ; targetFolderID : "14" ; targetLabelIDs : ";20;29" } + ListElement{ folderId : "Folder1" ; folderName : "Folder1" ; folderColor : "black" ; folderType : "folder" ; folderEntries : 20 ; fromDate : 300000 ; toDate : 15000000 ; isFolderSelected : false ; targetFolderID : "" ; targetLabelIDs : "" } + ListElement{ folderId : "Folder2" ; folderName : "Folder2" ; folderColor : "black" ; folderType : "folder" ; folderEntries : 30 ; fromDate : 300000 ; toDate : 15000000 ; isFolderSelected : true ; targetFolderID : "14" ; targetLabelIDs : ";20;29" } + ListElement{ folderId : "Folder3" ; folderName : "Folder3ToolongAndMustBeElidedSimilarToOnOfAccountsItJustNotNeedToBeThatLong" ; folderColor : "black" ; folderType : "folder" ; folderEntries : 40 ; fromDate : 300000 ; toDate : 15000000 ; isFolderSelected : false ; targetFolderID : "" ; targetLabelIDs : "" } + + ListElement{ folderId : "Label0" ; folderName : "Label-" ; folderColor : "black" ; folderType : "label" ; folderEntries : 10 ; fromDate : 300000 ; toDate : 15000000 ; isFolderSelected : false ; targetFolderID : "" ; targetLabelIDs : "" } + ListElement{ folderId : "Label1" ; folderName : "Label1" ; folderColor : "black" ; folderType : "label" ; folderEntries : 11 ; fromDate : 300000 ; toDate : 15000000 ; isFolderSelected : true ; targetFolderID : "14" ; targetLabelIDs : ";20;29" } + ListElement{ folderId : "Label2" ; folderName : "Label2" ; folderColor : "black" ; folderType : "label" ; folderEntries : 12 ; fromDate : 300000 ; toDate : 15000000 ; isFolderSelected : false ; targetFolderID : "" ; targetLabelIDs : "" } + ListElement{ folderId : "Label3" ; folderName : "Label3" ; folderColor : "black" ; folderType : "label" ; folderEntries : 13 ; fromDate : 300000 ; toDate : 15000000 ; isFolderSelected : true ; targetFolderID : "14" ; targetLabelIDs : ";20;29" } + + function addTargetLabelID ( id , label ) { structureFuncs.addTargetLabelID ( structureExternal , id , label ) } + function removeTargetLabelID ( id , label ) { structureFuncs.removeTargetLabelID ( structureExternal , id , label ) } + function setTargetFolderID ( id , label ) { structureFuncs.setTargetFolderID ( structureExternal , id , label ) } + function setFromToDate ( id , from, to ) { structureFuncs.setFromToDate ( structureExternal , id , from, to ) } + + function getID ( row ) { return row == -1 ? structureExternal.globalOptions.folderId : structureExternal.get(row).folderId } + function getById ( folderId ) { return structureFuncs.getById ( structureExternal , folderId ) } + function getFrom ( folderId ) { return structureExternal.getById ( folderId ) .fromDate } + function getTo ( folderId ) { return structureExternal.getById ( folderId ) .toDate } + function getTargetLabelIDs ( folderId ) { return structureExternal.getById ( folderId ) .getTargetLabelIDs } + function hasFolderWithName ( folderName ) { return structureFuncs.hasFolderWithName ( structureExternal , folderName ) } + + function hasTarget () { return structureFuncs.hasTarget(structureExternal) } + } + + ListModel{ + id: structurePM + + // group selectors + property bool selectedLabels : false + property bool selectedFolders : false + property bool atLeastOneSelected : true + + property var globalOptions: JSON.parse('{ "folderId" : "global--uniq" , "folderName" : "global" , "folderColor" : "black" , "folderType" : "" , "folderEntries" : 0 , "fromDate": 300000 , "toDate": 15000000 , "isFolderSelected" : false , "targetFolderID": "14" , "targetLabelIDs": ";20;29" }') + + ListElement{ folderId : "0" ; folderName : "INBOX" ; folderColor : "blue" ; folderType : "" ; folderEntries : 1 ; isFolderSelected : true ; targetFolderID : "" ; targetLabelIDs : "" ; } + ListElement{ folderId : "1" ; folderName : "Sent" ; folderColor : "blue" ; folderType : "" ; folderEntries : 2 ; isFolderSelected : false ; targetFolderID : "" ; targetLabelIDs : "" ; } + ListElement{ folderId : "2" ; folderName : "Spam" ; folderColor : "blue" ; folderType : "" ; folderEntries : 3 ; isFolderSelected : false ; targetFolderID : "" ; targetLabelIDs : "" ; } + ListElement{ folderId : "3" ; folderName : "Draft" ; folderColor : "blue" ; folderType : "" ; folderEntries : 4 ; isFolderSelected : true ; targetFolderID : "" ; targetLabelIDs : "" ; } + ListElement{ folderId : "6" ; folderName : "Archive" ; folderColor : "blue" ; folderType : "" ; folderEntries : 5 ; isFolderSelected : true ; targetFolderID : "" ; targetLabelIDs : "" ; } + + ListElement{ folderId : "14" ; folderName : "Folder0" ; folderColor : "blue" ; folderType : "folder" ; folderEntries : 10 ; isFolderSelected : true ; targetFolderID : "" ; targetLabelIDs : "" ; } + ListElement{ folderId : "15" ; folderName : "Folder1" ; folderColor : "green" ; folderType : "folder" ; folderEntries : 20 ; isFolderSelected : false ; targetFolderID : "" ; targetLabelIDs : "" ; } + ListElement{ folderId : "16" ; folderName : "Folder2" ; folderColor : "pink" ; folderType : "folder" ; folderEntries : 30 ; isFolderSelected : true ; targetFolderID : "" ; targetLabelIDs : "" ; } + ListElement{ folderId : "17" ; folderName : "Folder3ToolongAndMustBeElidedSimilarToOnOfAccountsItJustNotNeedToBeThatLong" ; folderColor : "orange" ; folderType : "folder" ; folderEntries : 40 ; isFolderSelected : true ; targetFolderID : "" ; targetLabelIDs : "" ; } + + ListElement{ folderId : "28" ; folderName : "Label0" ; folderColor : "red" ; folderType : "label" ; folderEntries : 10 ; isFolderSelected : false ; targetFolderID : "" ; targetLabelIDs : "" ; } + ListElement{ folderId : "29" ; folderName : "Label1" ; folderColor : "blue" ; folderType : "label" ; folderEntries : 11 ; isFolderSelected : true ; targetFolderID : "" ; targetLabelIDs : "" ; } + ListElement{ folderId : "20" ; folderName : "Label2" ; folderColor : "green" ; folderType : "label" ; folderEntries : 12 ; isFolderSelected : false ; targetFolderID : "" ; targetLabelIDs : "" ; } + ListElement{ folderId : "21" ; folderName : "Label3ToolongAndMustBeElidedSimilarToOnOfAccountsItJustNotNeedToBeThatLong" ; folderColor : "orange" ; folderType : "label" ; folderEntries : 40 ; isFolderSelected : true ; targetFolderID : "" ; targetLabelIDs : "" ; } + + + function setFolderSelection ( folderId , toSelect ) { structureFuncs.setFolderSelection ( structurePM , folderId , toSelect ) } + function selectType ( folderType , toSelect ) { structureFuncs.setTypeSelected ( structurePM , folderType , toSelect ) } + function setFromToDate ( id , from, to ) { structureFuncs.setFromToDate ( structureExternal , id , from, to ) } + + function getID ( row ) { return row == -1 ? structurePM.globalOptions.folderId : structurePM.get(row) .folderId } + function getById ( folderId ) { return structureFuncs.getById ( structurePM , folderId ) } + function getName ( folderId ) { return structurePM.getById ( folderId ) .folderName } + function getType ( folderId ) { return structurePM.getById ( folderId ) .folderType } + function getColor ( folderId ) { return structurePM.getById ( folderId ) .folderColor } + function getFrom ( folderId ) { return structurePM.getById ( folderId ) .fromDate } + function getTo ( folderId ) { return structurePM.getById ( folderId ) .toDate } + function getTargetLabelIDs ( folderId ) { return structurePM.getById ( folderId ) .getTargetLabelIDs } + function hasFolderWithName ( folderName ) { return structureFuncs.hasFolderWithName ( structurePM , folderName ) } + + onDataChanged: { + structureFuncs.updateSelection(structurePM) + } + } + + QtObject { + id: structureFuncs + + function setFolderSelection (model, id , toSelect ) { + console.log(" set folde sel", id, toSelect) + for (var i= -1; i createLabelOrFolder", address, fname, fcolor, isFolder, sourceID) + return (fname!="fail") + } + + function checkInternet() { + // nothing to do + } + + function loadImportReports(fname) { + console.log("load import reports for ", fname) + } + + + onToggleAutoStart: { + workAndClose("toggleAutoStart") + isAutoStart = (isAutoStart!=0) ? 0 : 1 + console.log (" Test: toggleAutoStart "+isAutoStart) + } + + function openReport() { + Qt.openUrlExternally("file:///home/cuto/") + } + + function sendImportReport(address, fname) { + console.log("sending import report from ", address, " file ", fname) + return !fname.includes("fail") + } + } +} diff --git a/internal/frontend/qt-common/Makefile.local b/internal/frontend/qt-common/Makefile.local new file mode 100644 index 00000000..1ca530a4 --- /dev/null +++ b/internal/frontend/qt-common/Makefile.local @@ -0,0 +1,6 @@ +clean: + rm -f moc.cpp + rm -f moc.go + rm -f moc.h + rm -f moc_cgo*.go + rm -f moc_moc.h diff --git a/internal/frontend/qt-common/account_model.go b/internal/frontend/qt-common/account_model.go new file mode 100644 index 00000000..87d32746 --- /dev/null +++ b/internal/frontend/qt-common/account_model.go @@ -0,0 +1,236 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build !nogui + +package qtcommon + +import ( + "fmt" + "github.com/therecipe/qt/core" +) + +// AccountInfo is an element of model. It contains all data for one account and +// it's aliases +type AccountInfo struct { + core.QObject + + _ string `property:"account"` + _ string `property:"userID"` + _ string `property:"status"` + _ string `property:"hostname"` + _ string `property:"password"` + _ string `property:"security"` + _ int `property:"portSMTP"` + _ int `property:"portIMAP"` + _ string `property:"aliases"` + _ bool `property:"isExpanded"` + _ bool `property:"isCombinedAddressMode"` +} + +// Constants for AccountsModel property map +const ( + Account = int(core.Qt__UserRole) + 1<= len(s.Accounts()) { + return NewAccountInfo(nil) + } + return s.Accounts()[index] +} + +// data return value for index and property +func (s *AccountsModel) data(index *core.QModelIndex, property int) *core.QVariant { + if !index.IsValid() { + return core.NewQVariant() + } + + if index.Row() >= len(s.Accounts()) { + return core.NewQVariant() + } + + var accountInfo = s.Accounts()[index.Row()] + + switch property { + case Account: + return NewQVariantString(accountInfo.Account()) + case UserID: + return NewQVariantString(accountInfo.UserID()) + case Status: + return NewQVariantString(accountInfo.Status()) + case Hostname: + return NewQVariantString(accountInfo.Hostname()) + case Password: + return NewQVariantString(accountInfo.Password()) + case Security: + return NewQVariantString(accountInfo.Security()) + case PortIMAP: + return NewQVariantInt(accountInfo.PortIMAP()) + case PortSMTP: + return NewQVariantInt(accountInfo.PortSMTP()) + case Aliases: + return NewQVariantString(accountInfo.Aliases()) + case IsExpanded: + return NewQVariantBool(accountInfo.IsExpanded()) + case IsCombinedAddressMode: + return NewQVariantBool(accountInfo.IsCombinedAddressMode()) + default: + return core.NewQVariant() + } +} + +// rowCount returns the dimension of model: number of rows is equivalent to number of items in list. +func (s *AccountsModel) rowCount(parent *core.QModelIndex) int { + return len(s.Accounts()) +} + +// columnCount returns the dimension of model: AccountsModel has only one column. +func (s *AccountsModel) columnCount(parent *core.QModelIndex) int { + return 1 +} + +// roleNames returns the names of available item properties. +func (s *AccountsModel) roleNames() map[int]*core.QByteArray { + return s.Roles() +} + +// addAccount is connected to the addAccount slot. +func (s *AccountsModel) addAccount(accountInfo *AccountInfo) { + s.BeginInsertRows(core.NewQModelIndex(), len(s.Accounts()), len(s.Accounts())) + s.SetAccounts(append(s.Accounts(), accountInfo)) + s.SetCount(len(s.Accounts())) + s.EndInsertRows() +} + +// toggleIsAvailable is connected to toggleIsAvailable slot. +func (s *AccountsModel) toggleIsAvailable(row int) { + var accountInfo = s.Accounts()[row] + currentStatus := accountInfo.Status() + if currentStatus == "active" { + accountInfo.SetStatus("disabled") + } else if currentStatus == "disabled" { + accountInfo.SetStatus("active") + } else { + accountInfo.SetStatus("error") + } + var pIndex = s.Index(row, 0, core.NewQModelIndex()) + s.DataChanged(pIndex, pIndex, []int{Status}) +} + +// removeAccount is connected to removeAccount slot. +func (s *AccountsModel) removeAccount(row int) { + s.BeginRemoveRows(core.NewQModelIndex(), row, row) + s.SetAccounts(append(s.Accounts()[:row], s.Accounts()[row+1:]...)) + s.SetCount(len(s.Accounts())) + s.EndRemoveRows() +} + +// Clear removes all items in model. +func (s *AccountsModel) Clear() { + s.BeginRemoveRows(core.NewQModelIndex(), 0, len(s.Accounts())) + s.SetAccounts(s.Accounts()[0:0]) + s.SetCount(len(s.Accounts())) + s.EndRemoveRows() +} + +// Dump prints the content of account models to console. +func (s *AccountsModel) Dump() { + fmt.Printf("Dimensions rows %d cols %d\n", s.rowCount(nil), s.columnCount(nil)) + for iAcc := 0; iAcc < s.rowCount(nil); iAcc++ { + var accountInfo = s.Accounts()[iAcc] + fmt.Printf(" %d. %s\n", iAcc, accountInfo.Account()) + } +} diff --git a/internal/frontend/qt-common/accounts.go b/internal/frontend/qt-common/accounts.go new file mode 100644 index 00000000..e731456f --- /dev/null +++ b/internal/frontend/qt-common/accounts.go @@ -0,0 +1,259 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build !nogui + +package qtcommon + +import ( + "fmt" + "strings" + "sync" + + "github.com/ProtonMail/proton-bridge/internal/bridge" + "github.com/ProtonMail/proton-bridge/internal/events" + "github.com/ProtonMail/proton-bridge/internal/frontend/types" + "github.com/ProtonMail/proton-bridge/internal/preferences" + "github.com/ProtonMail/proton-bridge/pkg/config" + "github.com/ProtonMail/proton-bridge/pkg/keychain" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" +) + +// QMLer sends signals to GUI +type QMLer interface { + ProcessFinished() + NotifyHasNoKeychain() + SetConnectionStatus(bool) + SetIsRestarting(bool) + SetAddAccountWarning(string, int) + NotifyBubble(int, string) + EmitEvent(string, string) + Quit() + + CanNotReachAPI() string + WrongMailboxPassword() string +} + +// Accounts holds functionality of users +type Accounts struct { + Model *AccountsModel + qml QMLer + um types.UserManager + prefs *config.Preferences + + authClient pmapi.Client + auth *pmapi.Auth + + LatestUserID string + accountMutex sync.Mutex +} + +// SetupAccounts will create Model and set QMLer and UserManager +func (a *Accounts) SetupAccounts(qml QMLer, um types.UserManager) { + a.Model = NewAccountsModel(nil) + a.qml = qml + a.um = um +} + +// LoadAccounts refreshes the current account list in GUI +func (a *Accounts) LoadAccounts() { + a.accountMutex.Lock() + defer a.accountMutex.Unlock() + + a.Model.Clear() + + users := a.um.GetUsers() + + // If there are no active accounts. + if len(users) == 0 { + log.Info("No active accounts") + return + } + for _, user := range users { + accInfo := NewAccountInfo(nil) + username := user.Username() + if username == "" { + username = user.ID() + } + accInfo.SetAccount(username) + + // Set status. + if user.IsConnected() { + accInfo.SetStatus("connected") + } else { + accInfo.SetStatus("disconnected") + } + + // Set login info. + accInfo.SetUserID(user.ID()) + accInfo.SetHostname(bridge.Host) + accInfo.SetPassword(user.GetBridgePassword()) + if a.prefs != nil { + accInfo.SetPortIMAP(a.prefs.GetInt(preferences.IMAPPortKey)) + accInfo.SetPortSMTP(a.prefs.GetInt(preferences.SMTPPortKey)) + } + + // Set aliases. + accInfo.SetAliases(strings.Join(user.GetAddresses(), ";")) + accInfo.SetIsExpanded(user.ID() == a.LatestUserID) + accInfo.SetIsCombinedAddressMode(user.IsCombinedAddressMode()) + + a.Model.addAccount(accInfo) + } + + // Updated can clear. + a.LatestUserID = "" +} + +// ClearCache signal to remove all DB files +func (a *Accounts) ClearCache() { + defer a.qml.ProcessFinished() + if err := a.um.ClearData(); err != nil { + log.Error("While clearing cache: ", err) + } + // Clearing data removes everything (db, preferences, ...) + // so everything has to be stopped and started again. + a.qml.SetIsRestarting(true) + a.qml.Quit() +} + +// ClearKeychain signal remove all accounts from keychains +func (a *Accounts) ClearKeychain() { + defer a.qml.ProcessFinished() + for _, user := range a.um.GetUsers() { + if err := a.um.DeleteUser(user.ID(), false); err != nil { + log.Error("While deleting user: ", err) + if err == keychain.ErrNoKeychainInstalled { // Probably not needed anymore. + a.qml.NotifyHasNoKeychain() + } + } + } +} + +// LogoutAccount signal to remove account +func (a *Accounts) LogoutAccount(iAccount int) { + defer a.qml.ProcessFinished() + userID := a.Model.get(iAccount).UserID() + user, err := a.um.GetUser(userID) + if err != nil { + log.Error("While logging out ", userID, ": ", err) + return + } + if err := user.Logout(); err != nil { + log.Error("While logging out ", userID, ": ", err) + } +} + +func (a *Accounts) showLoginError(err error, scope string) bool { + if err == nil { + a.qml.SetConnectionStatus(true) // If we are here connection is ok. + return false + } + log.Warnf("%s: %v", scope, err) + if err == pmapi.ErrAPINotReachable { + a.qml.SetConnectionStatus(false) + SendNotification(a.qml, TabAccount, a.qml.CanNotReachAPI()) + a.qml.ProcessFinished() + return true + } + a.qml.SetConnectionStatus(true) // If we are here connection is ok. + if err == pmapi.ErrUpgradeApplication { + a.qml.EmitEvent(events.UpgradeApplicationEvent, "") + return true + } + a.qml.SetAddAccountWarning(err.Error(), -1) + return true +} + +// Login signal returns: +// -1: when error occurred +// 0: when no 2FA and no MBOX +// 1: when has 2FA +// 2: when has no 2FA but have MBOX +func (a *Accounts) Login(login, password string) int { + var err error + a.authClient, a.auth, err = a.um.Login(login, password) + if a.showLoginError(err, "login") { + return -1 + } + if a.auth.HasTwoFactor() { + return 1 + } + if a.auth.HasMailboxPassword() { + return 2 + } + return 0 // No 2FA, no mailbox password. +} + +// Auth2FA returns: +// -1 : error (use SetAddAccountWarning to show message) +// 0 : single password mode +// 1 : two password mode +func (a *Accounts) Auth2FA(twoFacAuth string) int { + var err error + if a.auth == nil || a.authClient == nil { + err = fmt.Errorf("missing authentication in auth2FA %p %p", a.auth, a.authClient) + } else { + _, err = a.authClient.Auth2FA(twoFacAuth, a.auth) + } + + if a.showLoginError(err, "auth2FA") { + return -1 + } + + if a.auth.HasMailboxPassword() { + return 1 // Ask for mailbox password. + } + return 0 // One password. +} + +// AddAccount signal to add an account. It should close login modal +// ProcessFinished if ok. +func (a *Accounts) AddAccount(mailboxPassword string) int { + if a.auth == nil || a.authClient == nil { + log.Errorf("Missing authentication in addAccount %p %p", a.auth, a.authClient) + a.qml.SetAddAccountWarning(a.qml.WrongMailboxPassword(), -2) + return -1 + } + + user, err := a.um.FinishLogin(a.authClient, a.auth, mailboxPassword) + if err != nil { + log.WithError(err).Error("Login was unsuccessful") + a.qml.SetAddAccountWarning("Failure: "+err.Error(), -2) + return -1 + } + + a.LatestUserID = user.ID() + a.qml.EmitEvent(events.UserRefreshEvent, user.ID()) + a.qml.ProcessFinished() + return 0 +} + +// DeleteAccount by index in Model +func (a *Accounts) DeleteAccount(iAccount int, removePreferences bool) { + defer a.qml.ProcessFinished() + userID := a.Model.get(iAccount).UserID() + if err := a.um.DeleteUser(userID, removePreferences); err != nil { + log.Warn("deleteUser: cannot remove user: ", err) + if err == keychain.ErrNoKeychainInstalled { + a.qml.NotifyHasNoKeychain() + return + } + SendNotification(a.qml, TabSettings, err.Error()) + return + } +} diff --git a/internal/frontend/qt/logs.cpp b/internal/frontend/qt-common/common.cpp similarity index 58% rename from internal/frontend/qt/logs.cpp rename to internal/frontend/qt-common/common.cpp index e174e525..e29545a6 100644 --- a/internal/frontend/qt/logs.cpp +++ b/internal/frontend/qt-common/common.cpp @@ -1,23 +1,30 @@ // +build !nogui - -#include "logs.h" +#include "common.h" #include "_cgo_export.h" +#include #include #include +#include #include void messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &msg) { - Q_UNUSED(type); - Q_UNUSED(context); - - QByteArray localMsg = msg.toUtf8().prepend("WHITESPACE"); + Q_UNUSED( type ) + Q_UNUSED( context ) + QByteArray localMsg = msg.toUtf8().prepend("WHITESPACE"); logMsgPacked( const_cast( (localMsg.constData()) +10 ), localMsg.size()-10 ); //printf("Handler: %s (%s:%u, %s)\n", localMsg.constData(), context.file, context.line, context.function); } -void InstallMessageHandler() { qInstallMessageHandler(messageHandler); } +void InstallMessageHandler() { + qInstallMessageHandler(messageHandler); +} + + +void RegisterTypes() { + qRegisterMetaType >(); +} diff --git a/internal/frontend/qt-common/common.go b/internal/frontend/qt-common/common.go new file mode 100644 index 00000000..644eefb6 --- /dev/null +++ b/internal/frontend/qt-common/common.go @@ -0,0 +1,130 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build !nogui + +package qtcommon + +//#include "common.h" +import "C" + +import ( + "bufio" + "os" + "time" + + "github.com/sirupsen/logrus" + "github.com/therecipe/qt/core" +) + +var log = logrus.WithField("pkg", "frontend/qt-common") +var logQML = logrus.WithField("pkg", "frontend/qml") + +// RegisterTypes for vector of ints +func RegisterTypes() { // need to fix test message + C.RegisterTypes() +} + +func installMessageHandler() { + C.InstallMessageHandler() +} + +//export logMsgPacked +func logMsgPacked(data *C.char, len C.int) { + logQML.Warn(C.GoStringN(data, len)) +} + +// QtSetupCoreAndControls hanldes global setup of Qt. +// Should be called once per program. Probably once per thread is fine. +func QtSetupCoreAndControls(programName, programVersion string) { + installMessageHandler() + // Core setup. + core.QCoreApplication_SetApplicationName(programName) + core.QCoreApplication_SetApplicationVersion(programVersion) + // High DPI scaling for windows. + core.QCoreApplication_SetAttribute(core.Qt__AA_EnableHighDpiScaling, false) + // Software OpenGL: to avoid dedicated GPU. + core.QCoreApplication_SetAttribute(core.Qt__AA_UseSoftwareOpenGL, true) + // Basic style for QuickControls2 objects. + //quickcontrols2.QQuickStyle_SetStyle("material") +} + +// NewQByteArrayFromString is wrapper for new QByteArray from string +func NewQByteArrayFromString(name string) *core.QByteArray { + return core.NewQByteArray2(name, -1) +} + +// NewQVariantString is wrapper for QVariant alocator String +func NewQVariantString(data string) *core.QVariant { + return core.NewQVariant1(data) +} + +// NewQVariantStringArray is wrapper for QVariant alocator String Array +func NewQVariantStringArray(data []string) *core.QVariant { + return core.NewQVariant1(data) +} + +// NewQVariantBool is wrapper for QVariant alocator Bool +func NewQVariantBool(data bool) *core.QVariant { + return core.NewQVariant1(data) +} + +// NewQVariantInt is wrapper for QVariant alocator Int +func NewQVariantInt(data int) *core.QVariant { + return core.NewQVariant1(data) +} + +// NewQVariantLong is wrapper for QVariant alocator Int64 +func NewQVariantLong(data int64) *core.QVariant { + return core.NewQVariant1(data) +} + +// Pause used to show GUI tests +func Pause() { + time.Sleep(500 * time.Millisecond) +} + +// Longer pause used to diplay GUI tests +func PauseLong() { + time.Sleep(3 * time.Second) +} + +func ParsePMAPIError(err error, code int) error { + /* + if err == pmapi.ErrAPINotReachable { + code = ErrNoInternet + } + return errors.NewFromError(code, err) + */ + return nil +} + +// FIXME: Not working in test... +func WaitForEnter() { + log.Print("Press 'Enter' to continue...") + bufio.NewReader(os.Stdin).ReadBytes('\n') +} + +type Listener interface { + Add(string, chan<- string) +} + +func MakeAndRegisterEvent(eventListener Listener, event string) <-chan string { + ch := make(chan string) + eventListener.Add(event, ch) + return ch +} diff --git a/internal/frontend/qt/logs.h b/internal/frontend/qt-common/common.h similarity index 71% rename from internal/frontend/qt/logs.h rename to internal/frontend/qt-common/common.h index 48b392f5..699419fa 100644 --- a/internal/frontend/qt/logs.h +++ b/internal/frontend/qt-common/common.h @@ -10,8 +10,9 @@ extern "C" { #endif // C++ -void InstallMessageHandler(); -; + void InstallMessageHandler(); + void RegisterTypes(); + ; #ifdef __cplusplus } diff --git a/internal/frontend/qt-common/notification.go b/internal/frontend/qt-common/notification.go new file mode 100644 index 00000000..050545fc --- /dev/null +++ b/internal/frontend/qt-common/notification.go @@ -0,0 +1,40 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build !nogui + +package qtcommon + +// Positions of notification bubble +const ( + TabAccount = 0 + TabSettings = 1 + TabHelp = 2 + TabQuit = 4 + TabUpdates = 100 + TabAddAccount = -1 +) + +// Notifier show bubble notification at postion marked by int +type Notifier interface { + NotifyBubble(int, string) +} + +// SendNotification unifies notification in GUI +func SendNotification(qml Notifier, tabIndex int, msg string) { + qml.NotifyBubble(tabIndex, msg) +} diff --git a/internal/frontend/qt-common/path_status.go b/internal/frontend/qt-common/path_status.go new file mode 100644 index 00000000..e6845f92 --- /dev/null +++ b/internal/frontend/qt-common/path_status.go @@ -0,0 +1,81 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package qtcommon + +import ( + "io/ioutil" + "os" + "path/filepath" +) + +// PathStatus maps folder properties to flag +type PathStatus int + +// Definition of PathStatus flags +const ( + PathOK PathStatus = 1 << iota + PathEmptyPath + PathWrongPath + PathNotADir + PathWrongPermissions + PathDirEmpty +) + +// CheckPathStatus return PathStatus flag as int +func CheckPathStatus(path string) int { + stat := PathStatus(0) + // path is not empty + if path == "" { + stat |= PathEmptyPath + return int(stat) + } + // is dir + fi, err := os.Lstat(path) + if err != nil { + stat |= PathWrongPath + return int(stat) + } + if fi.IsDir() { + // can open + files, err := ioutil.ReadDir(path) + if err != nil { + stat |= PathWrongPermissions + return int(stat) + } + // empty folder + if len(files) == 0 { + stat |= PathDirEmpty + } + // can write + tmpFile := filepath.Join(path, "tmp") + for err == nil { + tmpFile += "tmp" + _, err = os.Lstat(tmpFile) + } + err = os.Mkdir(tmpFile, 0777) + if err != nil { + stat |= PathWrongPermissions + return int(stat) + } + os.Remove(tmpFile) + } else { + stat |= PathNotADir + } + stat |= PathOK + return int(stat) +} diff --git a/internal/frontend/qt-ie/Makefile.local b/internal/frontend/qt-ie/Makefile.local new file mode 100644 index 00000000..71a9058b --- /dev/null +++ b/internal/frontend/qt-ie/Makefile.local @@ -0,0 +1,60 @@ +QMLfiles=$(shell find ../qml/ -name "*.qml") $(shell find ../qml/ -name "qmldir") +FontAwesome=${CURDIR}/../share/fontawesome-webfont.ttf +ImageDir=${CURDIR}/../share/icons +Icons=$(shell find ${ImageDir} -name "*.png") +Icons+= share/images/folder_open.png share/images/envelope_open.png +MocDependencies= ./ui.go ./account_model.go ./folder_structure.go ./folder_functions.go +## EnumDependecies= ../backend/errors/errors.go ../backend/progress.go ../backend/source/enum.go ../frontend/enums.go + +all: ../qml/ImportExportUI/images moc.go ../qml/GuiIE.qml qmlcheck rcc.cpp + +## ./qml/GuiIE.qml: enums.sh ${EnumDependecies} +## ./enums.sh + +../qml/ProtonUI/fontawesome.ttf: + ln -sf ${FontAwesome} $@ +../qml/ProtonUI/images: + ln -sf ${ImageDir} $@ +../qml/ImportExportUI/images: + ln -sf ${ImageDir} $@ + +translate.ts: ${QMLfiles} + lupdate -recursive qml/ -ts $@ + +rcc.cpp: ${QMLfiles} ${Icons} resources.qrc + rm -f rcc.cpp rcc.qrc && qtrcc -o . + + +qmltest: + qmltestrunner -eventdelay 500 -import ../qml/ +qmlcheck: ../qml/ProtonUI/fontawesome.ttf ../qml/ImportExportUI/images ../qml/ProtonUI/images + qmlscene -verbose -I ../qml/ -f ../qml/tst_GuiIE.qml --quit +qmlpreview: ../qml/ProtonUI/fontawesome.ttf ../qml/ImportExportUI/images ../qml/ProtonUI/images + rm -f ../qml/*.qmlc ../qml/ProtonUI/*.qmlc ../qml/ImportExportUI/*.qmlc + qmlscene -verbose -I ../qml/ -f ../qml/tst_GuiIE.qml 2>&1 + +test: qmlcheck moc.go rcc.cpp + go test -v + +moc.go: ${MocDependencies} + qtmoc + +clean: + rm -rf linux/ + rm -rf darwin/ + rm -rf windows/ + rm -rf deploy/ + rm -f moc.cpp + rm -f moc.go + rm -f moc.h + rm -f moc_cgo*.go + rm -f moc_moc.h + rm -f rcc.cpp + rm -f rcc.qrc + rm -f rcc_cgo*.go + rm -f ../rcc.cpp + rm -f ../rcc.qrc + rm -f ../rcc_cgo*.go + rm -rf ../qml/ProtonUI/images + rm -f ../qml/ProtonUI/fontawesome.ttf + find ../qml -name *.qmlc -exec rm {} \; diff --git a/internal/frontend/qt-ie/README.md b/internal/frontend/qt-ie/README.md new file mode 100644 index 00000000..07dc9205 --- /dev/null +++ b/internal/frontend/qt-ie/README.md @@ -0,0 +1,55 @@ +# ProtonMail Import-Export Qt interface +Import-Export uses [Qt](https://www.qt.io) framework for creating appealing graphical +user interface. Package [therecipe/qt](https://github.com/therecipe/qt) is used +to implement Qt into [Go](https://www.goglang.com). + + +# For developers +The GUI is designed inside QML files. Communication with backend is done via +[frontend.go](./frontend.go). The API documentation is done via `go-doc`. + +## Setup +* if you don't have the system wide `go-1.8.1` download, install localy (e.g. + `~/build/go-1.8.1`) and setup: + + export GOROOT=~/build/go-1.8.1/go + export PATH=$GOROOT/bin:$PATH + +* go to your working directory and export `$GOPATH` + + export GOPATH=`Pwd` + mkdir -p $GOPATH/bin + export PATH=$PATH:$GOPATH/bin + + +* if you dont have system wide `Qt-5.8.0` + [download](https://download.qt.io/official_releases/qt/5.8/5.8.0/qt-opensource-linux-x64-5.8.0.run), + install locally (e.g. `~/build/qt/qt-5.8.0`) and setup: + + export QT_DIR=~/build/qt/qt-5.8.0 + export PATH=$QT_DIR/5.8/gcc_64/bin:$PATH + +* `Go-Qt` setup (installation is system dependent see + [therecipe/qt/README](https://github.com/therecipe/qt/blob/master/README.md) + for details) + + go get -u -v github.com/therecipe/qt/cmd/... + $GOPATH/bin/qtsetup + +## Compile +* it is necessary to compile the Qt-C++ with go for resources and meta-objects + + make -f Makefile.local + +* FIXME the rcc file is implicitly generated with `package main`. This needs to + be changed to `package qtie` manually +* check that user interface is working + + make -f Makefile.local test + +## Test + + make -f Makefile.local qmlpreview + +## Deploy +* before compilation of Import-Export it is necessary to run compilation of Qt-C++ part (done in makefile) diff --git a/internal/frontend/qt-ie/enums.go b/internal/frontend/qt-ie/enums.go new file mode 100644 index 00000000..4e21681b --- /dev/null +++ b/internal/frontend/qt-ie/enums.go @@ -0,0 +1,68 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build !nogui + +package qtie + +import ( + "github.com/therecipe/qt/core" +) + +// Folder Type +const ( + FolderTypeSystem = "" + FolderTypeLabel = "label" + FolderTypeFolder = "folder" + FolderTypeExternal = "external" +) + +// Status +const ( + StatusNoInternet = "noInternet" + StatusCheckingInternet = "internetCheck" + StatusNewVersionAvailable = "oldVersion" + StatusUpToDate = "upToDate" + StatusForceUpdate = "forceupdate" +) + +// Constants for data map +const ( + // Account info + Account = int(core.Qt__UserRole) + 1<. + +// +build !nogui + +package qtie + +import ( + qtcommon "github.com/ProtonMail/proton-bridge/internal/frontend/qt-common" + "github.com/therecipe/qt/core" +) + +// ErrorDetail stores information about email and error +type ErrorDetail struct { + MailSubject, MailDate, MailFrom, InputFolder, ErrorMessage string +} + +func init() { + ErrorListModel_QRegisterMetaType() +} + +// ErrorListModel to sending error details to Qt +type ErrorListModel struct { + core.QAbstractListModel + + // Qt list model + _ func() `constructor:"init"` + _ map[int]*core.QByteArray `property:"roles"` + _ int `property:"count"` + + Details []*ErrorDetail +} + +func (s *ErrorListModel) init() { + s.SetRoles(map[int]*core.QByteArray{ + MailSubject: qtcommon.NewQByteArrayFromString("mailSubject"), + MailDate: qtcommon.NewQByteArrayFromString("mailDate"), + MailFrom: qtcommon.NewQByteArrayFromString("mailFrom"), + InputFolder: qtcommon.NewQByteArrayFromString("inputFolder"), + ErrorMessage: qtcommon.NewQByteArrayFromString("errorMessage"), + }) + // basic QAbstractListModel mehods + s.ConnectData(s.data) + s.ConnectRowCount(s.rowCount) + s.ConnectColumnCount(s.columnCount) + s.ConnectRoleNames(s.roleNames) +} + +func (s *ErrorListModel) data(index *core.QModelIndex, role int) *core.QVariant { + if !index.IsValid() { + return core.NewQVariant() + } + + if index.Row() >= len(s.Details) { + return core.NewQVariant() + } + + var p = s.Details[index.Row()] + + switch role { + case MailSubject: + return qtcommon.NewQVariantString(p.MailSubject) + case MailDate: + return qtcommon.NewQVariantString(p.MailDate) + case MailFrom: + return qtcommon.NewQVariantString(p.MailFrom) + case InputFolder: + return qtcommon.NewQVariantString(p.InputFolder) + case ErrorMessage: + return qtcommon.NewQVariantString(p.ErrorMessage) + default: + return core.NewQVariant() + } +} + +func (s *ErrorListModel) rowCount(parent *core.QModelIndex) int { return len(s.Details) } +func (s *ErrorListModel) columnCount(parent *core.QModelIndex) int { return 1 } +func (s *ErrorListModel) roleNames() map[int]*core.QByteArray { return s.Roles() } + +// Add more errors to list +func (s *ErrorListModel) Add(more []*ErrorDetail) { + s.BeginInsertRows(core.NewQModelIndex(), len(s.Details), len(s.Details)) + s.Details = append(s.Details, more...) + s.SetCount(len(s.Details)) + s.EndInsertRows() +} + +// Clear removes all items in model +func (s *ErrorListModel) Clear() { + s.BeginRemoveRows(core.NewQModelIndex(), 0, len(s.Details)) + s.Details = s.Details[0:0] + s.SetCount(len(s.Details)) + s.EndRemoveRows() +} + +func (s *ErrorListModel) load(importLogFileName string) { + /* + err := backend.LoopDetailsInFile(importLogFileName, func(d *backend.MessageDetails) { + if d.MessageID != "" { // imported ok + return + } + ed := &ErrorDetail{ + MailSubject: d.Subject, + MailDate: d.Time, + MailFrom: d.From, + InputFolder: d.Folder, + ErrorMessage: d.Error, + } + s.Add([]*ErrorDetail{ed}) + }) + if err != nil { + log.Errorf("load import report from %q: %v", importLogFileName, err) + } + */ +} diff --git a/internal/frontend/qt-ie/export.go b/internal/frontend/qt-ie/export.go new file mode 100644 index 00000000..d85a3cbb --- /dev/null +++ b/internal/frontend/qt-ie/export.go @@ -0,0 +1,125 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build !nogui + +package qtie + +import ( + "github.com/ProtonMail/proton-bridge/internal/transfer" +) + +const ( + TypeEML = "EML" + TypeMBOX = "MBOX" +) + +func (f *FrontendQt) LoadStructureForExport(addressOrID string) { + var err error + defer func() { + if err != nil { + f.showError(err) + f.Qml.ExportStructureLoadFinished(false) + } else { + f.Qml.ExportStructureLoadFinished(true) + } + }() + + if f.transfer, err = f.ie.GetEMLExporter(addressOrID, ""); err != nil { + return + } + + f.PMStructure.Clear() + sourceMailboxes, err := f.transfer.SourceMailboxes() + if err != nil { + return + } + for _, mbox := range sourceMailboxes { + rule := f.transfer.GetRule(mbox) + f.PMStructure.addEntry(newFolderInfo(mbox, rule)) + } + + f.PMStructure.transfer = f.transfer +} + +func (f *FrontendQt) StartExport(rootPath, login, fileType string, attachEncryptedBody bool) { + var target transfer.TargetProvider + if fileType == TypeEML { + target = transfer.NewEMLProvider(rootPath) + } else if fileType == TypeMBOX { + + target = transfer.NewMBOXProvider(rootPath) + } else { + log.Errorln("Wrong file format:", fileType) + return + } + f.transfer.ChangeTarget(target) + f.transfer.SetSkipEncryptedMessages(!attachEncryptedBody) + progress := f.transfer.Start() + f.setProgressManager(progress) + + /* + TODO + f.Qml.SetProgress(0.0) + f.Qml.SetProgressDescription(backend.ProgressInit) + f.Qml.SetTotal(0) + + settings := backend.ExportSettings{ + FilePath: fpath, + Login: login, + AttachEncryptedBody: attachEncryptedBody, + DateBegin: 0, + DateEnd: 0, + Labels: make(map[string]string), + } + + if fileType == "EML" { + settings.FileTypeID = backend.EMLFormat + } else if fileType == "MBOX" { + settings.FileTypeID = backend.MBOXFormat + } else { + log.Errorln("Wrong file format:", fileType) + return + } + + username, _, err := backend.ExtractUsername(login) + if err != nil { + log.Error("qtfrontend: cannot retrieve username from alias: ", err) + return + } + + settings.User, err = backend.ExtractCurrentUser(username) + if err != nil && !errors.IsCode(err, errors.ErrUnlockUser) { + return + } + + for _, entity := range f.PMStructure.entities { + if entity.IsFolderSelected { + settings.Labels[entity.FolderName] = entity.FolderId + } + } + + settings.DateBegin = f.PMStructure.GlobalOptions.FromDate + settings.DateEnd = f.PMStructure.GlobalOptions.ToDate + + settings.PM = backend.NewProcessManager() + f.setHandlers(settings.PM) + + log.Debugln("start export", settings.FilePath) + go backend.Export(f.panicHandler, settings) + */ +} diff --git a/internal/frontend/qt-ie/folder_functions.go b/internal/frontend/qt-ie/folder_functions.go new file mode 100644 index 00000000..7c156a72 --- /dev/null +++ b/internal/frontend/qt-ie/folder_functions.go @@ -0,0 +1,539 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build !nogui + +package qtie + +import ( + "errors" + "strings" + + qtcommon "github.com/ProtonMail/proton-bridge/internal/frontend/qt-common" + "github.com/ProtonMail/proton-bridge/internal/transfer" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/therecipe/qt/core" +) + +const ( + GlobalOptionIndex = -1 +) + +var AllFolderInfoRoles = []int{ + FolderId, + FolderName, + FolderColor, + FolderType, + FolderEntries, + IsFolderSelected, + FolderFromDate, + FolderToDate, + TargetFolderID, + TargetLabelIDs, +} + +func getTargetHashes(mboxes []transfer.Mailbox) (targetFolderID, targetLabelIDs string) { + for _, targetMailbox := range mboxes { + if targetMailbox.IsExclusive { + targetFolderID = targetMailbox.Hash() + } else { + targetLabelIDs += targetMailbox.Hash() + ";" + } + } + + targetLabelIDs = strings.Trim(targetLabelIDs, ";") + return +} + +func isSystemMailbox(mbox transfer.Mailbox) bool { + return pmapi.IsSystemLabel(mbox.ID) +} + +func newFolderInfo(mbox transfer.Mailbox, rule *transfer.Rule) *FolderInfo { + targetFolderID, targetLabelIDs := getTargetHashes(rule.TargetMailboxes) + + entry := &FolderInfo{ + mailbox: mbox, + FolderEntries: 1, + FromDate: rule.FromTime, + ToDate: rule.ToTime, + IsFolderSelected: rule.Active, + TargetFolderID: targetFolderID, + TargetLabelIDs: targetLabelIDs, + } + + entry.FolderType = FolderTypeSystem + if !isSystemMailbox(mbox) { + if mbox.IsExclusive { + entry.FolderType = FolderTypeFolder + } else { + entry.FolderType = FolderTypeLabel + } + } + + return entry +} + +func (s *FolderStructure) saveRule(info *FolderInfo) error { + if s.transfer == nil { + return errors.New("missing transfer") + } + sourceMbox := info.mailbox + if !info.IsFolderSelected { + s.transfer.UnsetRule(sourceMbox) + return nil + } + allTargetMboxes, err := s.transfer.TargetMailboxes() + if err != nil { + return err + } + var targetMboxes []transfer.Mailbox + for _, target := range allTargetMboxes { + targetHash := target.Hash() + if info.TargetFolderID == targetHash || strings.Contains(info.TargetLabelIDs, targetHash) { + targetMboxes = append(targetMboxes, target) + } + } + + return s.transfer.SetRule(sourceMbox, targetMboxes, info.FromDate, info.ToDate) +} + +func (s *FolderInfo) updateTgtLblIDs(targetLabelsSet map[string]struct{}) { + targets := []string{} + for key := range targetLabelsSet { + targets = append(targets, key) + } + s.TargetLabelIDs = strings.Join(targets, ";") +} + +func (s *FolderInfo) clearTgtLblIDs() { + s.TargetLabelIDs = "" +} + +func (s *FolderInfo) AddTargetLabel(targetID string) { + if targetID == "" { + return + } + targetLabelsSet := s.getSetOfLabels() + targetLabelsSet[targetID] = struct{}{} + s.updateTgtLblIDs(targetLabelsSet) +} + +func (s *FolderInfo) RemoveTargetLabel(targetID string) { + if targetID == "" { + return + } + targetLabelsSet := s.getSetOfLabels() + delete(targetLabelsSet, targetID) + s.updateTgtLblIDs(targetLabelsSet) +} + +func (s *FolderInfo) IsType(askType string) bool { + return s.FolderType == askType +} + +func (s *FolderInfo) getSetOfLabels() (uniqSet map[string]struct{}) { + uniqSet = make(map[string]struct{}) + for _, label := range s.TargetLabelIDList() { + uniqSet[label] = struct{}{} + } + return +} + +func (s *FolderInfo) TargetLabelIDList() []string { + return strings.FieldsFunc( + s.TargetLabelIDs, + func(c rune) bool { return c == ';' }, + ) +} + +// Get data +func (s *FolderStructure) data(index *core.QModelIndex, role int) *core.QVariant { + row, isValid := index.Row(), index.IsValid() + if !isValid || row >= s.getCount() { + log.Warnln("Wrong index", isValid, row) + return core.NewQVariant() + } + + var f = s.get(row) + + switch role { + case FolderId: + return qtcommon.NewQVariantString(f.mailbox.Hash()) + case FolderName, int(core.Qt__DisplayRole): + return qtcommon.NewQVariantString(f.mailbox.Name) + case FolderColor: + return qtcommon.NewQVariantString(f.mailbox.Color) + case FolderType: + return qtcommon.NewQVariantString(f.FolderType) + case FolderEntries: + return qtcommon.NewQVariantInt(f.FolderEntries) + case FolderFromDate: + return qtcommon.NewQVariantLong(f.FromDate) + case FolderToDate: + return qtcommon.NewQVariantLong(f.ToDate) + case IsFolderSelected: + return qtcommon.NewQVariantBool(f.IsFolderSelected) + case TargetFolderID: + return qtcommon.NewQVariantString(f.TargetFolderID) + case TargetLabelIDs: + return qtcommon.NewQVariantString(f.TargetLabelIDs) + default: + log.Warnln("Wrong role", role) + return core.NewQVariant() + } +} + +// Get header data (table view, tree view) +func (s *FolderStructure) headerData(section int, orientation core.Qt__Orientation, role int) *core.QVariant { + if role != int(core.Qt__DisplayRole) { + return core.NewQVariant() + } + + if orientation == core.Qt__Horizontal { + return qtcommon.NewQVariantString("Column") + } + + return qtcommon.NewQVariantString("Row") +} + +// Flags is editable +func (s *FolderStructure) flags(index *core.QModelIndex) core.Qt__ItemFlag { + if !index.IsValid() { + return core.Qt__ItemIsEnabled + } + + // can do here also: core.NewQAbstractItemModelFromPointer(s.Pointer()).Flags(index) | core.Qt__ItemIsEditable + // or s.FlagsDefault(index) | core.Qt__ItemIsEditable + return core.Qt__ItemIsEnabled | core.Qt__ItemIsSelectable | core.Qt__ItemIsEditable +} + +// Set data +func (s *FolderStructure) setData(index *core.QModelIndex, value *core.QVariant, role int) bool { + log.Debugf("SET DATA %d", role) + if !index.IsValid() { + return false + } + if index.Row() < GlobalOptionIndex || index.Row() > s.getCount() || index.Column() != 1 { + return false + } + item := s.get(index.Row()) + t := true + switch role { + case FolderId, FolderType: + log. + WithField("structure", s). + WithField("row", index.Row()). + WithField("column", index.Column()). + WithField("role", role). + WithField("isEdit", role == int(core.Qt__EditRole)). + Warn("Set constant role forbiden") + case FolderName: + item.mailbox.Name = value.ToString() + case FolderColor: + item.mailbox.Color = value.ToString() + case FolderEntries: + item.FolderEntries = value.ToInt(&t) + case FolderFromDate: + item.FromDate = value.ToLongLong(&t) + case FolderToDate: + item.ToDate = value.ToLongLong(&t) + case IsFolderSelected: + item.IsFolderSelected = value.ToBool() + case TargetFolderID: + item.TargetFolderID = value.ToString() + case TargetLabelIDs: + item.TargetLabelIDs = value.ToString() + default: + log.Debugln("uknown role ", s, index.Row(), index.Column(), role, role == int(core.Qt__EditRole)) + return false + } + s.changedEntityRole(index.Row(), index.Row(), role) + return true +} + +// Dimension of model: number of rows is equivalent to number of items in list +func (s *FolderStructure) rowCount(parent *core.QModelIndex) int { + return s.getCount() +} + +func (s *FolderStructure) getCount() int { + return len(s.entities) +} + +// Returns names of available item properties +func (s *FolderStructure) roleNames() map[int]*core.QByteArray { + return s.Roles() +} + +// Clear removes all items in model +func (s *FolderStructure) Clear() { + s.BeginResetModel() + if s.getCount() != 0 { + s.entities = []*FolderInfo{} + } + + s.GlobalOptions = FolderInfo{ + mailbox: transfer.Mailbox{ + Name: "=", + }, + FromDate: 0, + ToDate: 0, + TargetFolderID: "", + TargetLabelIDs: "", + } + s.EndResetModel() +} + +// Method connected to addEntry slot +func (s *FolderStructure) addEntry(entry *FolderInfo) { + s.insertEntry(entry, s.getCount()) +} + +// NewUniqId which is not in map yet. +func (s *FolderStructure) newUniqId() (name string) { + name = s.GlobalOptions.mailbox.Name + mbox := transfer.Mailbox{Name: name} + for newVal := byte(name[0]); true; newVal++ { + mbox.Name = string([]byte{newVal}) + if s.getRowById(mbox.Hash()) < GlobalOptionIndex { + return + } + } + return +} + +// Method connected to addEntry slot +func (s *FolderStructure) insertEntry(entry *FolderInfo, i int) { + s.BeginInsertRows(core.NewQModelIndex(), i, i) + s.entities = append(s.entities[:i], append([]*FolderInfo{entry}, s.entities[i:]...)...) + s.EndInsertRows() + // update global if conflict + if entry.mailbox.Hash() == s.GlobalOptions.mailbox.Hash() { + globalName := s.newUniqId() + s.GlobalOptions.mailbox.Name = globalName + } +} + +func (s *FolderStructure) GetInfo(row int) FolderInfo { + return *s.get(row) +} + +func (s *FolderStructure) changedEntityRole(rowStart int, rowEnd int, roles ...int) { + if rowStart < GlobalOptionIndex || rowEnd < GlobalOptionIndex { + return + } + if rowStart < 0 || rowStart >= s.getCount() { + rowStart = 0 + } + if rowEnd < 0 || rowEnd >= s.getCount() { + rowEnd = s.getCount() + } + if rowStart > rowEnd { + tmp := rowStart + rowStart = rowEnd + rowEnd = tmp + } + indexStart := s.Index(rowStart, 0, core.NewQModelIndex()) + indexEnd := s.Index(rowEnd, 0, core.NewQModelIndex()) + s.updateSelection(indexStart, indexEnd, roles) + s.DataChanged(indexStart, indexEnd, roles) +} + +func (s *FolderStructure) setFolderSelection(id string, toSelect bool) { + log.Debugf("set folder selection %q %b", id, toSelect) + i := s.getRowById(id) + // + info := s.get(i) + before := info.IsFolderSelected + info.IsFolderSelected = toSelect + if err := s.saveRule(info); err != nil { + s.get(i).IsFolderSelected = before + log.WithError(err).WithField("id", id).WithField("toSelect", toSelect).Error("Cannot set selection") + return + } + // + s.changedEntityRole(i, i, IsFolderSelected) +} + +func (s *FolderStructure) setTargetFolderID(id, target string) { + log.Debugf("set targetFolderID %q %q", id, target) + i := s.getRowById(id) + // + info := s.get(i) + //s.get(i).TargetFolderID = target + before := info.TargetFolderID + info.TargetFolderID = target + if err := s.saveRule(info); err != nil { + info.TargetFolderID = before + log.WithError(err).WithField("id", id).WithField("target", target).Error("Cannot set target") + return + } + // + s.changedEntityRole(i, i, TargetFolderID) + if target == "" { // do not import + before := info.TargetLabelIDs + info.clearTgtLblIDs() + if err := s.saveRule(info); err != nil { + info.TargetLabelIDs = before + log.WithError(err).WithField("id", id).WithField("target", target).Error("Cannot set target") + return + } + s.changedEntityRole(i, i, TargetLabelIDs) + } +} + +func (s *FolderStructure) addTargetLabelID(id, label string) { + log.Debugf("add target label id %q %q", id, label) + if label == "" { + return + } + i := s.getRowById(id) + info := s.get(i) + before := info.TargetLabelIDs + info.AddTargetLabel(label) + if err := s.saveRule(info); err != nil { + info.TargetLabelIDs = before + log.WithError(err).WithField("id", id).WithField("label", label).Error("Cannot add label") + return + } + s.changedEntityRole(i, i, TargetLabelIDs) +} + +func (s *FolderStructure) removeTargetLabelID(id, label string) { + log.Debugf("remove label id %q %q", id, label) + if label == "" { + return + } + i := s.getRowById(id) + info := s.get(i) + before := info.TargetLabelIDs + info.RemoveTargetLabel(label) + if err := s.saveRule(info); err != nil { + info.TargetLabelIDs = before + log.WithError(err).WithField("id", id).WithField("label", label).Error("Cannot remove label") + return + } + s.changedEntityRole(i, i, TargetLabelIDs) +} + +func (s *FolderStructure) setFromToDate(id string, from, to int64) { + log.Debugf("set from to date %q %d %d", id, from, to) + i := s.getRowById(id) + info := s.get(i) + beforeFrom := info.FromDate + beforeTo := info.ToDate + info.FromDate = from + info.ToDate = to + if err := s.saveRule(info); err != nil { + info.FromDate = beforeFrom + info.ToDate = beforeTo + log.WithError(err).WithField("id", id).WithField("from", from).WithField("to", to).Error("Cannot set date") + return + } + s.changedEntityRole(i, i, FolderFromDate, FolderToDate) +} + +func (s *FolderStructure) selectType(folderType string, toSelect bool) { + log.Debugf("set type %q %b", folderType, toSelect) + iFirst, iLast := -1, -1 + for i, entity := range s.entities { + if entity.IsType(folderType) { + if iFirst == -1 { + iFirst = i + } + before := entity.IsFolderSelected + entity.IsFolderSelected = toSelect + if err := s.saveRule(entity); err != nil { + entity.IsFolderSelected = before + log.WithError(err).WithField("i", i).WithField("type", folderType).WithField("toSelect", toSelect).Error("Cannot select type") + } + iLast = i + } + } + if iFirst != -1 { + s.changedEntityRole(iFirst, iLast, IsFolderSelected) + } +} + +func (s *FolderStructure) updateSelection(topLeft *core.QModelIndex, bottomRight *core.QModelIndex, roles []int) { + for _, role := range roles { + switch role { + case IsFolderSelected: + s.SetSelectedFolders(true) + s.SetSelectedLabels(true) + s.SetAtLeastOneSelected(false) + for _, entity := range s.entities { + if entity.IsFolderSelected { + s.SetAtLeastOneSelected(true) + } else { + if entity.IsType(FolderTypeFolder) { + s.SetSelectedFolders(false) + } + if entity.IsType(FolderTypeLabel) { + s.SetSelectedLabels(false) + } + } + if !s.IsSelectedFolders() && !s.IsSelectedLabels() && s.IsAtLeastOneSelected() { + break + } + } + default: + } + } +} + +func (s *FolderStructure) hasFolderWithName(name string) bool { + for _, entity := range s.entities { + if entity.mailbox.Name == name { + return true + } + } + return false +} + +func (s *FolderStructure) getRowById(id string) (row int) { + for row = GlobalOptionIndex; row < s.getCount(); row++ { + if id == s.get(row).mailbox.Hash() { + return + } + } + row = GlobalOptionIndex - 1 + return +} + +func (s *FolderStructure) hasTarget() bool { + for row := 0; row < s.getCount(); row++ { + if s.get(row).TargetFolderID != "" { + return true + } + } + return false +} + +// Getter for account info pointer +// index out of array length returns empty folder info to avoid segfault +// index == GlobalOptionIndex is set to access global options +func (s *FolderStructure) get(index int) *FolderInfo { + if index < GlobalOptionIndex || index >= s.getCount() { + return &FolderInfo{} + } + if index == GlobalOptionIndex { + return &s.GlobalOptions + } + return s.entities[index] +} diff --git a/internal/frontend/qt-ie/folder_structure.go b/internal/frontend/qt-ie/folder_structure.go new file mode 100644 index 00000000..ed11cf36 --- /dev/null +++ b/internal/frontend/qt-ie/folder_structure.go @@ -0,0 +1,196 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build !nogui + +package qtie + +// TODO: +// Proposal for new structure +// It will be a bit more memory but much better performance +// * Rules: +// * rules []Rule /QAbstracItemModel/ +// * globalFromDate int64 +// * globalToDate int64 +// * globalLabel Mbox +// * targetPath string +// * filterEncryptedBodies bool +// * Rule +// * sourceMbox: Mbox +// * targetFolders: []Mbox /QAbstracItemModel/ (all available target folders) +// * targetLabels: []Mbox /QAbstracItemModel/ (all available target labels) +// * selectedLabelColors: QStringList (need reset context on change) (show label list) +// * fromDate int64 +// * toDate int64 +// * Mbox +// * IsActive bool (show checkox) +// * Name string (show name) +// * Type string (show icon) +// * Color string (show icon) +// +// Biggest update: add folder or label for all roles update target models + +import ( + qtcommon "github.com/ProtonMail/proton-bridge/internal/frontend/qt-common" + "github.com/ProtonMail/proton-bridge/internal/transfer" + "github.com/therecipe/qt/core" +) + +// FolderStructure model providing container for items (folder info) to QML +// +// QML ListView connects the model from Go and it shows item (entities) +// information. +// +// Copied and edited from `github.com/therecipe/qt/internal/examples/sailfish/listview` +// +// NOTE: When implementing a model it is important to remember that QAbstractItemModel does not store any data itself !!!! +// see https://doc.qt.io/qt-5/model-view-programming.html#designing-a-model +type FolderStructure struct { + core.QAbstractListModel + + // QtObject Constructor + _ func() `constructor:"init"` + + // List of item properties + // + // All available item properties are inside the map + _ map[int]*core.QByteArray `property:"roles"` + + // The data storage + // + // The slice with all entities. It is not accessed directly but using + // `data(index,role)` + entities []*FolderInfo + GlobalOptions FolderInfo + + transfer *transfer.Transfer + + // Global Folders/Labels selection flag, use setter from QML + _ bool `property:"selectedLabels"` + _ bool `property:"selectedFolders"` + _ bool `property:"atLeastOneSelected"` + + // Getters (const) + _ func() int `slot:"getCount"` + _ func(index int) string `slot:"getID"` + _ func(id string) string `slot:"getName"` + _ func(id string) string `slot:"getType"` + _ func(id string) string `slot:"getColor"` + _ func(id string) int64 `slot:"getFrom"` + _ func(id string) int64 `slot:"getTo"` + _ func(id string) string `slot:"getTargetLabelIDs"` + _ func(name string) bool `slot:"hasFolderWithName"` + _ func() bool `slot:"hasTarget"` + + // TODO get folders + // TODO get labels + // TODO get selected labels + // TODO get selected folder + + // Setters (emits DataChanged) + _ func(fileType string, toSelect bool) `slot:"selectType"` + _ func(id string, toSelect bool) `slot:"setFolderSelection"` + _ func(id string, target string) `slot:"setTargetFolderID"` + _ func(id string, label string) `slot:"addTargetLabelID"` + _ func(id string, label string) `slot:"removeTargetLabelID"` + _ func(id string, from, to int64) `slot:"setFromToDate"` +} + +// FolderInfo is the element of model +// +// It contains all data for one structure entry +type FolderInfo struct { + /* + FolderId string + FolderFullPath string + FolderColor string + FolderFullName string + */ + mailbox transfer.Mailbox // TODO how to reference from qml source mailbox to go target mailbox + FolderType string + FolderEntries int // todo remove + IsFolderSelected bool + FromDate int64 // Unix seconds + ToDate int64 // Unix seconds + TargetFolderID string // target ID TODO: this will be hash + TargetLabelIDs string // semicolon separated list of label ID same here +} + +// Registration of new metatype before creating instance +// +// NOTE: check it is run once per program. write a log +func init() { + FolderStructure_QRegisterMetaType() +} + +// Constructor +// +// Creates the map for item properties and connects the methods +func (s *FolderStructure) init() { + s.SetRoles(map[int]*core.QByteArray{ + FolderId: qtcommon.NewQByteArrayFromString("folderId"), + FolderName: qtcommon.NewQByteArrayFromString("folderName"), + FolderColor: qtcommon.NewQByteArrayFromString("folderColor"), + FolderType: qtcommon.NewQByteArrayFromString("folderType"), + FolderEntries: qtcommon.NewQByteArrayFromString("folderEntries"), + IsFolderSelected: qtcommon.NewQByteArrayFromString("isFolderSelected"), + FolderFromDate: qtcommon.NewQByteArrayFromString("fromDate"), + FolderToDate: qtcommon.NewQByteArrayFromString("toDate"), + TargetFolderID: qtcommon.NewQByteArrayFromString("targetFolderID"), + TargetLabelIDs: qtcommon.NewQByteArrayFromString("targetLabelIDs"), + }) + + // basic QAbstractListModel mehods + s.ConnectGetCount(s.getCount) + s.ConnectRowCount(s.rowCount) + s.ConnectColumnCount(func(parent *core.QModelIndex) int { return 1 }) // for list it should be always 1 + s.ConnectData(s.data) + s.ConnectHeaderData(s.headerData) + s.ConnectRoleNames(s.roleNames) + // Editable QAbstractListModel needs: https://doc.qt.io/qt-5/model-view-programming.html#an-editable-model + s.ConnectSetData(s.setData) + s.ConnectFlags(s.flags) + + // Custom FolderStructure slots to export + + // Getters (const) + s.ConnectGetID(func(row int) string { return s.get(row).mailbox.Hash() }) + s.ConnectGetType(func(id string) string { row := s.getRowById(id); return s.get(row).FolderType }) + s.ConnectGetName(func(id string) string { row := s.getRowById(id); return s.get(row).mailbox.Name }) + s.ConnectGetColor(func(id string) string { row := s.getRowById(id); return s.get(row).mailbox.Color }) + s.ConnectGetFrom(func(id string) int64 { row := s.getRowById(id); return s.get(row).FromDate }) + s.ConnectGetTo(func(id string) int64 { row := s.getRowById(id); return s.get(row).ToDate }) + s.ConnectGetTargetLabelIDs(func(id string) string { row := s.getRowById(id); return s.get(row).TargetLabelIDs }) + s.ConnectHasFolderWithName(s.hasFolderWithName) + s.ConnectHasTarget(s.hasTarget) + + // Setters (emits DataChanged) + s.ConnectSelectType(s.selectType) + s.ConnectSetFolderSelection(s.setFolderSelection) + s.ConnectSetTargetFolderID(s.setTargetFolderID) + s.ConnectAddTargetLabelID(s.addTargetLabelID) + s.ConnectRemoveTargetLabelID(s.removeTargetLabelID) + s.ConnectSetFromToDate(s.setFromToDate) + + s.GlobalOptions = FolderInfo{ + mailbox: transfer.Mailbox{Name: "="}, + FromDate: 0, + ToDate: 0, + TargetFolderID: "", + TargetLabelIDs: "", + } +} diff --git a/internal/frontend/qt-ie/folder_structure_test.go b/internal/frontend/qt-ie/folder_structure_test.go new file mode 100644 index 00000000..50207f64 --- /dev/null +++ b/internal/frontend/qt-ie/folder_structure_test.go @@ -0,0 +1,65 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build !nogui + +package qtie + +import ( + "testing" +) + +func hasNumberOfLabels(tb testing.TB, folder *FolderInfo, expected int) { + if current := len(folder.TargetLabelIDList()); current != expected { + tb.Error("Folder has wrong number of labels. Expected", expected, "has", current, " labels", folder.TargetLabelIDs) + } +} + +func labelStringEquals(tb testing.TB, folder *FolderInfo, expected string) { + if current := folder.TargetLabelIDs; current != expected { + tb.Error("Folder returned wrong labels. Expected", expected, "has", current, " labels", folder.TargetLabelIDs) + } +} + +func TestLabelInfoUniqSet(t *testing.T) { + folder := &FolderInfo{} + labelStringEquals(t, folder, "") + hasNumberOfLabels(t, folder, 0) + // add label + folder.AddTargetLabel("blah") + hasNumberOfLabels(t, folder, 1) + labelStringEquals(t, folder, "blah") + // + folder.AddTargetLabel("blah___") + hasNumberOfLabels(t, folder, 2) + labelStringEquals(t, folder, "blah;blah___") + // add same label + folder.AddTargetLabel("blah") + hasNumberOfLabels(t, folder, 2) + // remove label + folder.RemoveTargetLabel("blah") + hasNumberOfLabels(t, folder, 1) + // + folder.AddTargetLabel("blah___") + hasNumberOfLabels(t, folder, 1) + // remove same label + folder.RemoveTargetLabel("blah") + hasNumberOfLabels(t, folder, 1) + // add again label + folder.AddTargetLabel("blah") + hasNumberOfLabels(t, folder, 2) +} diff --git a/internal/frontend/qt-ie/frontend.go b/internal/frontend/qt-ie/frontend.go new file mode 100644 index 00000000..6b6305f5 --- /dev/null +++ b/internal/frontend/qt-ie/frontend.go @@ -0,0 +1,497 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build !nogui + +package qtie + +import ( + "errors" + "os" + "strconv" + "time" + + "github.com/ProtonMail/proton-bridge/internal/events" + qtcommon "github.com/ProtonMail/proton-bridge/internal/frontend/qt-common" + "github.com/ProtonMail/proton-bridge/internal/frontend/types" + "github.com/ProtonMail/proton-bridge/internal/transfer" + "github.com/ProtonMail/proton-bridge/pkg/config" + "github.com/ProtonMail/proton-bridge/pkg/listener" + "github.com/ProtonMail/proton-bridge/pkg/updates" + + "github.com/therecipe/qt/core" + "github.com/therecipe/qt/gui" + "github.com/therecipe/qt/qml" + "github.com/therecipe/qt/widgets" + + "github.com/sirupsen/logrus" + "github.com/skratchdot/open-golang/open" +) + +var log = logrus.WithField("pkg", "frontend-qt-ie") + +// FrontendQt is API between Import-Export and Qt +// +// With this interface it is possible to control Qt-Gui interface using pointers to +// Qt and QML objects. QML signals and slots are connected via methods of GoQMLInterface. +type FrontendQt struct { + panicHandler types.PanicHandler + config *config.Config + eventListener listener.Listener + updates types.Updater + ie types.ImportExporter + + App *widgets.QApplication // Main Application pointer + View *qml.QQmlApplicationEngine // QML engine pointer + MainWin *core.QObject // Pointer to main window inside QML + Qml *GoQMLInterface // Object accessible from both Go and QML for methods and signals + Accounts qtcommon.Accounts // Providing data for accounts ListView + + programName string // Program name + programVersion string // Program version + buildVersion string // Program build version + + PMStructure *FolderStructure // Providing data for account labels and folders for ProtonMail account + ExternalStructure *FolderStructure // Providing data for account labels and folders for MBOX, EML or external IMAP account + ErrorList *ErrorListModel // Providing data for error reporting + + transfer *transfer.Transfer + + notifyHasNoKeychain bool +} + +// New is constructor for Import-Export Qt-Go interface +func New( + version, buildVersion string, + panicHandler types.PanicHandler, + config *config.Config, + eventListener listener.Listener, + updates types.Updater, + ie types.ImportExporter, +) *FrontendQt { + f := &FrontendQt{ + panicHandler: panicHandler, + config: config, + programName: "ProtonMail Import-Export", + programVersion: "v" + version, + eventListener: eventListener, + buildVersion: buildVersion, + updates: updates, + ie: ie, + } + + // Nicer string for OS + currentOS := core.QSysInfo_PrettyProductName() + ie.SetCurrentOS(currentOS) + + log.Debugf("New Qt frontend: %p", f) + return f +} + +// IsAppRestarting for Import-Export is always false i.e never restarts +func (s *FrontendQt) IsAppRestarting() bool { + return false +} + +// Loop function for Import-Export interface. It runs QtExecute in main thread +// with no additional function. +func (s *FrontendQt) Loop(setupError error) (err error) { + if setupError != nil { + s.notifyHasNoKeychain = true + } + go func() { + defer s.panicHandler.HandlePanic() + s.watchEvents() + }() + err = s.QtExecute(func(s *FrontendQt) error { return nil }) + return err +} + +func (s *FrontendQt) watchEvents() { + internetOffCh := qtcommon.MakeAndRegisterEvent(s.eventListener, events.InternetOffEvent) + internetOnCh := qtcommon.MakeAndRegisterEvent(s.eventListener, events.InternetOnEvent) + restartBridgeCh := qtcommon.MakeAndRegisterEvent(s.eventListener, events.RestartBridgeEvent) + addressChangedCh := qtcommon.MakeAndRegisterEvent(s.eventListener, events.AddressChangedEvent) + addressChangedLogoutCh := qtcommon.MakeAndRegisterEvent(s.eventListener, events.AddressChangedLogoutEvent) + logoutCh := qtcommon.MakeAndRegisterEvent(s.eventListener, events.LogoutEvent) + updateApplicationCh := qtcommon.MakeAndRegisterEvent(s.eventListener, events.UpgradeApplicationEvent) + newUserCh := qtcommon.MakeAndRegisterEvent(s.eventListener, events.UserRefreshEvent) + for { + select { + case <-internetOffCh: + s.Qml.SetConnectionStatus(false) + case <-internetOnCh: + s.Qml.SetConnectionStatus(true) + case <-restartBridgeCh: + s.Qml.SetIsRestarting(true) + s.App.Quit() + case address := <-addressChangedCh: + s.Qml.NotifyAddressChanged(address) + case address := <-addressChangedLogoutCh: + s.Qml.NotifyAddressChangedLogout(address) + case userID := <-logoutCh: + user, err := s.ie.GetUser(userID) + if err != nil { + return + } + s.Qml.NotifyLogout(user.Username()) + case <-updateApplicationCh: + s.Qml.ProcessFinished() + s.Qml.NotifyUpdate() + case <-newUserCh: + s.Qml.LoadAccounts() + } + } +} + +func (s *FrontendQt) qtSetupQmlAndStructures() { + s.App = widgets.NewQApplication(len(os.Args), os.Args) + // view + s.View = qml.NewQQmlApplicationEngine(s.App) + // Add Go-QML Import-Export + s.Qml = NewGoQMLInterface(nil) + s.Qml.SetFrontend(s) // provides access + s.View.RootContext().SetContextProperty("go", s.Qml) + // Add AccountsModel + s.Accounts.SetupAccounts(s.Qml, s.ie) + s.View.RootContext().SetContextProperty("accountsModel", s.Accounts.Model) + + // Add ProtonMail FolderStructure + s.PMStructure = NewFolderStructure(nil) + s.View.RootContext().SetContextProperty("structurePM", s.PMStructure) + + // Add external FolderStructure + s.ExternalStructure = NewFolderStructure(nil) + s.View.RootContext().SetContextProperty("structureExternal", s.ExternalStructure) + + // Add error list modal + s.ErrorList = NewErrorListModel(nil) + s.View.RootContext().SetContextProperty("errorList", s.ErrorList) + s.Qml.ConnectLoadImportReports(s.ErrorList.load) + + // Import path and load QML files + s.View.AddImportPath("qrc:///") + s.View.Load(core.NewQUrl3("qrc:/uiie.qml", 0)) + + // TODO set the first start flag + log.Error("Get FirstStart: Not implemented") + //if prefs.Get(prefs.FirstStart) == "true" { + if false { + s.Qml.SetIsFirstStart(true) + } else { + s.Qml.SetIsFirstStart(false) + } + + // Notify user about error during initialization. + if s.notifyHasNoKeychain { + s.Qml.NotifyHasNoKeychain() + } +} + +// QtExecute in main for starting Qt application +// +// It is needed to have just one Qt application per program (at least per same +// thread). This functions reads the main user interface defined in QML files. +// The files are appended to library by Qt-QRC. +func (s *FrontendQt) QtExecute(Procedure func(*FrontendQt) error) error { + qtcommon.QtSetupCoreAndControls(s.programName, s.programVersion) + s.qtSetupQmlAndStructures() + // Check QML is loaded properly + if len(s.View.RootObjects()) == 0 { + //return errors.New(errors.ErrQApplication, "QML not loaded properly") + return errors.New("QML not loaded properly") + } + // Obtain main window (need for invoke method) + s.MainWin = s.View.RootObjects()[0] + // Injected procedure for out-of-main-thread applications + if err := Procedure(s); err != nil { + return err + } + // Loop + if ret := gui.QGuiApplication_Exec(); ret != 0 { + //err := errors.New(errors.ErrQApplication, "Event loop ended with return value: %v", string(ret)) + err := errors.New("Event loop ended with return value: " + string(ret)) + log.Warnln("QGuiApplication_Exec: ", err) + return err + } + log.Debug("Closing...") + log.Error("Set FirstStart: Not implemented") + //prefs.Set(prefs.FirstStart, "false") + return nil +} + +func (s *FrontendQt) openLogs() { + go open.Run(s.config.GetLogDir()) +} + +func (s *FrontendQt) openReport() { + go open.Run(s.Qml.ImportLogFileName()) +} + +func (s *FrontendQt) openDownloadLink() { + go open.Run(s.updates.GetDownloadLink()) +} + +func (s *FrontendQt) sendImportReport(address, reportFile string) (isOK bool) { + /* + accname := "[No account logged in]" + if s.Accounts.Count() > 0 { + accname = s.Accounts.get(0).Account() + } + + basename := filepath.Base(reportFile) + req := pmapi.ReportReq{ + OS: core.QSysInfo_ProductType(), + OSVersion: core.QSysInfo_PrettyProductName(), + Title: "[Import Export] Import report: " + basename, + Description: "Sending import report file in attachment.", + Username: accname, + Email: address, + } + + report, err := os.Open(reportFile) + if err != nil { + log.Errorln("report file open:", err) + isOK = false + } + req.AddAttachment("log", basename, report) + + c := pmapi.NewClient(backend.APIConfig, "import_reporter") + err = c.Report(req) + if err != nil { + log.Errorln("while sendReport:", err) + isOK = false + return + } + log.Infof("Report %q send successfully", basename) + isOK = true + */ + return false +} + +// sendBug is almost idetical to bridge +func (s *FrontendQt) sendBug(description, emailClient, address string) (isOK bool) { + isOK = true + var accname = "No account logged in" + if s.Accounts.Model.Count() > 0 { + accname = s.Accounts.Model.Get(0).Account() + } + if err := s.ie.ReportBug( + core.QSysInfo_ProductType(), + core.QSysInfo_PrettyProductName(), + description, + accname, + address, + emailClient, + ); err != nil { + log.Errorln("while sendBug:", err) + isOK = false + } + return +} + +// checkInternet is almost idetical to bridge +func (s *FrontendQt) checkInternet() { + s.Qml.SetConnectionStatus(s.ie.CheckConnection() == nil) +} + +func (s *FrontendQt) showError(err error) { + code := 0 // TODO err.Code() + s.Qml.SetErrorDescription(err.Error()) + log.WithField("code", code).Errorln(err.Error()) + s.Qml.NotifyError(code) +} + +func (s *FrontendQt) emitEvent(evType, msg string) { + s.eventListener.Emit(evType, msg) +} + +func (s *FrontendQt) setProgressManager(progress *transfer.Progress) { + s.Qml.ConnectPauseProcess(func() { progress.Pause("user") }) + s.Qml.ConnectResumeProcess(progress.Resume) + s.Qml.ConnectCancelProcess(func(clearUnfinished bool) { + // TODO clear unfinished + progress.Stop() + }) + + go func() { + defer func() { + s.Qml.DisconnectPauseProcess() + s.Qml.DisconnectResumeProcess() + s.Qml.DisconnectCancelProcess() + s.Qml.SetProgress(1) + }() + + //TODO get log file (in old code it was here, but this is ugly place probably somewhere else) + updates := progress.GetUpdateChannel() + for range updates { + if progress.IsStopped() { + break + } + failed, imported, _, _, total := progress.GetCounts() + if total != 0 { // udate total + s.Qml.SetTotal(int(total)) + } + s.Qml.SetProgressFails(int(failed)) + s.Qml.SetProgressDescription(progress.PauseReason()) // TODO add description when changing folders? + if total > 0 { + newProgress := float32(imported+failed) / float32(total) + if newProgress >= 0 && newProgress != s.Qml.Progress() { + s.Qml.SetProgress(newProgress) + s.Qml.ProgressChanged(newProgress) + } + } + } + + // TODO fatal error? + }() +} + +// StartUpdate is identical to bridge +func (s *FrontendQt) StartUpdate() { + progress := make(chan updates.Progress) + go func() { // Update progress in QML. + defer s.panicHandler.HandlePanic() + for current := range progress { + s.Qml.SetProgress(current.Processed) + s.Qml.SetProgressDescription(strconv.Itoa(current.Description)) + // Error happend + if current.Err != nil { + log.Error("update progress: ", current.Err) + s.Qml.UpdateFinished(true) + return + } + // Finished everything OK. + if current.Description >= updates.InfoQuitApp { + s.Qml.UpdateFinished(false) + time.Sleep(3 * time.Second) // Just notify. + s.Qml.SetIsRestarting(current.Description == updates.InfoRestartApp) + s.App.Quit() + return + } + } + }() + go func() { + defer s.panicHandler.HandlePanic() + s.updates.StartUpgrade(progress) + }() +} + +// isNewVersionAvailable is identical to bridge +// return 0 when local version is fine +// return 1 when new version is available +func (s *FrontendQt) isNewVersionAvailable(showMessage bool) { + go func() { + defer s.Qml.ProcessFinished() + isUpToDate, latestVersionInfo, err := s.updates.CheckIsUpToDate() + if err != nil { + log.Warnln("Cannot retrieve version info: ", err) + s.checkInternet() + return + } + s.Qml.SetConnectionStatus(true) // if we are here connection is ok + if isUpToDate { + s.Qml.SetUpdateState(StatusUpToDate) + if showMessage { + s.Qml.NotifyVersionIsTheLatest() + } + return + } + s.Qml.SetNewversion(latestVersionInfo.Version) + s.Qml.SetChangelog(latestVersionInfo.ReleaseNotes) + s.Qml.SetBugfixes(latestVersionInfo.ReleaseFixedBugs) + s.Qml.SetLandingPage(latestVersionInfo.LandingPage) + s.Qml.SetDownloadLink(latestVersionInfo.GetDownloadLink()) + s.Qml.SetUpdateState(StatusNewVersionAvailable) + }() +} + +func (s *FrontendQt) resetSource() { + if s.transfer != nil { + s.transfer.ResetRules() + if err := s.loadStructuresForImport(); err != nil { + log.WithError(err).Error("Cannot reload structures after reseting rules.") + } + } +} + +// getLocalVersionInfo is identical to bridge. +func (s *FrontendQt) getLocalVersionInfo() { + defer s.Qml.ProcessFinished() + localVersion := s.updates.GetLocalVersion() + s.Qml.SetNewversion(localVersion.Version) + s.Qml.SetChangelog(localVersion.ReleaseNotes) + s.Qml.SetBugfixes(localVersion.ReleaseFixedBugs) +} + +// LeastUsedColor is intended to return color for creating a new inbox or label. +func (s *FrontendQt) leastUsedColor() string { + if s.transfer == nil { + log.Errorln("Getting least used color before transfer exist.") + return "#7272a7" + } + + m, err := s.transfer.TargetMailboxes() + + if err != nil { + log.Errorln("Getting least used color:", err) + s.showError(err) + } + + return transfer.LeastUsedColor(m) +} + +// createLabelOrFolder performs an IE target mailbox creation. +func (s *FrontendQt) createLabelOrFolder(email, name, color string, isLabel bool, sourceID string) bool { + // Prepare new mailbox. + m := transfer.Mailbox{ + Name: name, + Color: color, + IsExclusive: !isLabel, + } + + // Select least used color if no color given. + if m.Color == "" { + m.Color = s.leastUsedColor() + } + + // Create mailbox. + newLabel, err := s.transfer.CreateTargetMailbox(m) + + if err != nil { + log.Errorln("Folder/Label creating:", err) + s.showError(err) + return false + } + + // TODO: notify UI of newly added folders/labels + /*errc := s.PMStructure.Load(email, false) + if errc != nil { + s.showError(errc) + return false + }*/ + + if sourceID != "" { + if isLabel { + s.ExternalStructure.addTargetLabelID(sourceID, newLabel.ID) + } else { + s.ExternalStructure.setTargetFolderID(sourceID, newLabel.ID) + } + } + + return true +} diff --git a/internal/frontend/qt-ie/frontend_nogui.go b/internal/frontend/qt-ie/frontend_nogui.go new file mode 100644 index 00000000..673800b8 --- /dev/null +++ b/internal/frontend/qt-ie/frontend_nogui.go @@ -0,0 +1,55 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build nogui + +package qtie + +import ( + "fmt" + "net/http" + + "github.com/ProtonMail/proton-bridge/internal/frontend/types" + "github.com/ProtonMail/proton-bridge/pkg/config" + "github.com/ProtonMail/proton-bridge/pkg/listener" + "github.com/sirupsen/logrus" +) + +var log = logrus.WithField("pkg", "frontend-nogui") //nolint[gochecknoglobals] + +type FrontendHeadless struct{} + +func (s *FrontendHeadless) Loop(credentialsError error) error { + log.Info("Check status on localhost:8081") + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "IE is running") + }) + return http.ListenAndServe(":8081", nil) +} + +func (s *FrontendHeadless) IsAppRestarting() bool { return false } + +func New( + version, buildVersion string, + panicHandler types.PanicHandler, + config *config.Config, + eventListener listener.Listener, + updates types.Updater, + ie types.ImportExporter, +) *FrontendHeadless { + return &FrontendHeadless{} +} diff --git a/internal/frontend/qt-ie/import.go b/internal/frontend/qt-ie/import.go new file mode 100644 index 00000000..058dff58 --- /dev/null +++ b/internal/frontend/qt-ie/import.go @@ -0,0 +1,89 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build !nogui + +package qtie + +import "github.com/ProtonMail/proton-bridge/internal/transfer" + +// wrapper for QML +func (f *FrontendQt) setupAndLoadForImport(isFromIMAP bool, sourcePath, sourceEmail, sourcePassword, sourceServer, sourcePort, targetAddress string) { + var err error + defer func() { + if err != nil { + f.showError(err) + f.Qml.ImportStructuresLoadFinished(false) + } else { + f.Qml.ImportStructuresLoadFinished(true) + } + }() + + if isFromIMAP { + f.transfer, err = f.ie.GetRemoteImporter(targetAddress, sourceEmail, sourcePassword, sourceServer, sourcePort) + if err != nil { + return + } + } else { + f.transfer, err = f.ie.GetLocalImporter(targetAddress, sourcePath) + if err != nil { + return + } + } + + if err := f.loadStructuresForImport(); err != nil { + return + } +} + +func (f *FrontendQt) loadStructuresForImport() error { + f.PMStructure.Clear() + targetMboxes, err := f.transfer.TargetMailboxes() + if err != nil { + return err + } + for _, mbox := range targetMboxes { + rule := &transfer.Rule{} + f.PMStructure.addEntry(newFolderInfo(mbox, rule)) + } + + f.ExternalStructure.Clear() + sourceMboxes, err := f.transfer.SourceMailboxes() + if err != nil { + return err + } + for _, mbox := range sourceMboxes { + rule := f.transfer.GetRule(mbox) + f.ExternalStructure.addEntry(newFolderInfo(mbox, rule)) + } + + f.ExternalStructure.transfer = f.transfer + + return nil +} + +func (f *FrontendQt) StartImport(email string) { // TODO email not needed + f.Qml.SetProgressDescription("init") // TODO use const + f.Qml.SetProgressFails(0) + f.Qml.SetProgress(0.0) + f.Qml.SetTotal(1) + f.Qml.SetImportLogFileName("") + f.ErrorList.Clear() + + progress := f.transfer.Start() + f.setProgressManager(progress) +} diff --git a/internal/frontend/qt/logs.go b/internal/frontend/qt-ie/notification.go similarity index 72% rename from internal/frontend/qt/logs.go rename to internal/frontend/qt-ie/notification.go index 9b75950c..74d8f1de 100644 --- a/internal/frontend/qt/logs.go +++ b/internal/frontend/qt-ie/notification.go @@ -17,22 +17,16 @@ // +build !nogui -package qt +package qtie -//#include "logs.h" -import "C" - -import ( - "github.com/sirupsen/logrus" +const ( + TabGlobal = 0 + TabSettings = 1 + TabHelp = 2 + TabQuit = 4 + TabAddAccount = -1 ) -func installMessageHandler() { - C.InstallMessageHandler() -} - -//export logMsgPacked -func logMsgPacked(data *C.char, len C.int) { - log.WithFields(logrus.Fields{ - "pkg": "frontend-qml", - }).Warnln(C.GoStringN(data, len)) +func (s *FrontendQt) SendNotification(tabIndex int, msg string) { + s.Qml.NotifyBubble(tabIndex, msg) } diff --git a/internal/frontend/qt-ie/types.go b/internal/frontend/qt-ie/types.go new file mode 100644 index 00000000..8d759887 --- /dev/null +++ b/internal/frontend/qt-ie/types.go @@ -0,0 +1,25 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build !nogui + +package qtie + +type panicHandler interface { + HandlePanic() + SendReport(interface{}) +} diff --git a/internal/frontend/qt-ie/ui.go b/internal/frontend/qt-ie/ui.go new file mode 100644 index 00000000..e70663ae --- /dev/null +++ b/internal/frontend/qt-ie/ui.go @@ -0,0 +1,189 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build !nogui + +package qtie + +import ( + "runtime" + + qtcommon "github.com/ProtonMail/proton-bridge/internal/frontend/qt-common" + "github.com/therecipe/qt/core" +) + +// GoQMLInterface between go and qml +// +// Here we implements all the signals / methods. +type GoQMLInterface struct { + core.QObject + + _ func() `constructor:"init"` + + _ string `property:"currentAddress"` + _ string `property:"goos"` + _ bool `property:"isFirstStart"` + _ bool `property:"isRestarting"` + _ bool `property:"isConnectionOK"` + + _ string `property:lastError` + _ float32 `property:progress` + _ string `property:progressDescription` + _ int `property:progressFails` + _ int `property:total` + _ string `property:importLogFileName` + + _ string `property:"programTitle"` + _ string `property:"newversion"` + _ string `property:"downloadLink"` + _ string `property:"landingPage"` + _ string `property:"changelog"` + _ string `property:"bugfixes"` + + // translations + _ string `property:"wrongCredentials"` + _ string `property:"wrongMailboxPassword"` + _ string `property:"canNotReachAPI"` + _ string `property:"credentialsNotRemoved"` + _ string `property:"versionCheckFailed"` + // + _ func(isAvailable bool) `signal:"setConnectionStatus"` + _ func(updateState string) `signal:"setUpdateState"` + _ func() `slot:"checkInternet"` + + _ func() `signal:"processFinished"` + _ func(okay bool) `signal:"exportStructureLoadFinished"` + _ func(okay bool) `signal:"importStructuresLoadFinished"` + _ func() `signal:"openManual"` + _ func(showMessage bool) `signal:"runCheckVersion"` + _ func() `slot:"getLocalVersionInfo"` + _ func(fname string) `slot:"loadImportReports"` + + _ func() `slot:"quit"` + _ func() `slot:"loadAccounts"` + _ func() `slot:"openLogs"` + _ func() `slot:"openDownloadLink"` + _ func() `slot:"openReport"` + _ func() `slot:"clearCache"` + _ func() `slot:"clearKeychain"` + _ func() `signal:"highlightSystray"` + _ func() `signal:"normalSystray"` + + _ func(showMessage bool) `slot:"isNewVersionAvailable"` + _ func() string `slot:"getBackendVersion"` + + _ func(description, client, address string) bool `slot:"sendBug"` + _ func(address, fname string) bool `slot:"sendImportReport"` + _ func(address string) `slot:"loadStructureForExport"` + _ func() string `slot:"leastUsedColor"` + _ func(username string, name string, color string, isLabel bool, sourceID string) bool `slot:"createLabelOrFolder"` + _ func(fpath, address, fileType string, attachEncryptedBody bool) `slot:"startExport"` + _ func(email string) `slot:"startImport"` + _ func() `slot:"resetSource"` + + _ func(isFromIMAP bool, sourcePath, sourceEmail, sourcePassword, sourceServe, sourcePort, targetAddress string) `slot:"setupAndLoadForImport"` + + _ string `property:"progressInit"` + + _ func(path string) int `slot:"checkPathStatus"` + + _ func(evType string, msg string) `signal:"emitEvent"` + _ func(tabIndex int, message string) `signal:"notifyBubble"` + + _ func() `signal:"bubbleClosed"` + _ func() `signal:"simpleErrorHappen"` + _ func() `signal:"askErrorHappen"` + _ func() `signal:"retryErrorHappen"` + _ func() `signal:"pauseProcess"` + _ func() `signal:"resumeProcess"` + _ func(clearUnfinished bool) `signal:"cancelProcess"` + + _ func(iAccount int, prefRem bool) `slot:"deleteAccount"` + _ func(iAccount int) `slot:"logoutAccount"` + _ func(login, password string) int `slot:"login"` + _ func(twoFacAuth string) int `slot:"auth2FA"` + _ func(mailboxPassword string) int `slot:"addAccount"` + _ func(message string, changeIndex int) `signal:"setAddAccountWarning"` + + _ func() `signal:"notifyVersionIsTheLatest"` + _ func() `signal:"notifyKeychainRebuild"` + _ func() `signal:"notifyHasNoKeychain"` + _ func() `signal:"notifyUpdate"` + _ func(accname string) `signal:"notifyLogout"` + _ func(accname string) `signal:"notifyAddressChanged"` + _ func(accname string) `signal:"notifyAddressChangedLogout"` + + _ func() `slot:"startUpdate"` + _ func(hasError bool) `signal:"updateFinished"` + + // errors + _ func() `signal:"answerRetry"` + _ func(all bool) `signal:"answerSkip"` + _ func(errCode int) `signal:"notifyError"` + _ string `property:"errorDescription"` +} + +// Constructor +func (s *GoQMLInterface) init() {} + +// SetFrontend connects all slots and signals from Go to QML +func (s *GoQMLInterface) SetFrontend(f *FrontendQt) { + s.ConnectQuit(f.App.Quit) + + s.ConnectLoadAccounts(f.Accounts.LoadAccounts) + s.ConnectOpenLogs(f.openLogs) + s.ConnectOpenDownloadLink(f.openDownloadLink) + s.ConnectOpenReport(f.openReport) + s.ConnectClearCache(f.Accounts.ClearCache) + s.ConnectClearKeychain(f.Accounts.ClearKeychain) + + s.ConnectSendBug(f.sendBug) + s.ConnectSendImportReport(f.sendImportReport) + + s.ConnectDeleteAccount(f.Accounts.DeleteAccount) + s.ConnectLogoutAccount(f.Accounts.LogoutAccount) + s.ConnectLogin(f.Accounts.Login) + s.ConnectAuth2FA(f.Accounts.Auth2FA) + s.ConnectAddAccount(f.Accounts.AddAccount) + + s.SetGoos(runtime.GOOS) + s.SetIsRestarting(false) + s.SetProgramTitle(f.programName) + + s.ConnectGetLocalVersionInfo(f.getLocalVersionInfo) + s.ConnectIsNewVersionAvailable(f.isNewVersionAvailable) + s.ConnectGetBackendVersion(func() string { + return f.programVersion + }) + + s.ConnectCheckInternet(f.checkInternet) + + s.ConnectLoadStructureForExport(f.LoadStructureForExport) + s.ConnectSetupAndLoadForImport(f.setupAndLoadForImport) + s.ConnectResetSource(f.resetSource) + s.ConnectLeastUsedColor(f.leastUsedColor) + s.ConnectCreateLabelOrFolder(f.createLabelOrFolder) + + s.ConnectStartExport(f.StartExport) + s.ConnectStartImport(f.StartImport) + + s.ConnectCheckPathStatus(qtcommon.CheckPathStatus) + + s.ConnectStartUpdate(f.StartUpdate) + + s.ConnectEmitEvent(f.emitEvent) +} diff --git a/internal/frontend/qt/Makefile.local b/internal/frontend/qt/Makefile.local index a50f35bd..826c5907 100644 --- a/internal/frontend/qt/Makefile.local +++ b/internal/frontend/qt/Makefile.local @@ -17,14 +17,14 @@ translate.ts: ${QMLfiles} lupdate -recursive qml/ -ts $@ rcc.cpp: ${QMLfiles} ${Icons} resources.qrc - rm -f rcc.cpp rcc.qrc && qtrcc -o . + rm -f rcc.cpp rcc.qrc && qtrcc -o . qmltest: - qmltestrunner -eventdelay 500 -import ./qml/ -qmlcheck : ../qml/ProtonUI/fontawesome.ttf ../qml/ProtonUI/images + qmltestrunner -eventdelay 500 -import ../qml/ +qmlcheck: ../qml/ProtonUI/fontawesome.ttf ../qml/ProtonUI/images qmlscene -I ../qml/ -f ../qml/tst_Gui.qml --quit -qmlpreview : ../qml/ProtonUI/fontawesome.ttf ../qml/ProtonUI/images +qmlpreview: ../qml/ProtonUI/fontawesome.ttf ../qml/ProtonUI/images rm -f ../qml/*.qmlc ../qml/BridgeUI/*.qmlc qmlscene -verbose -I ../qml/ -f ../qml/tst_Gui.qml #qmlscene -qmljsdebugger=port:3768,block -verbose -I ../qml/ -f ../qml/tst_Gui.qml diff --git a/internal/frontend/qt/frontend.go b/internal/frontend/qt/frontend.go index 57b3a3c0..2748021e 100644 --- a/internal/frontend/qt/frontend.go +++ b/internal/frontend/qt/frontend.go @@ -40,6 +40,7 @@ import ( "github.com/ProtonMail/proton-bridge/internal/bridge" "github.com/ProtonMail/proton-bridge/internal/events" "github.com/ProtonMail/proton-bridge/internal/frontend/autoconfig" + "github.com/ProtonMail/proton-bridge/internal/frontend/qt-common" "github.com/ProtonMail/proton-bridge/internal/frontend/types" "github.com/ProtonMail/proton-bridge/internal/preferences" "github.com/ProtonMail/proton-bridge/pkg/config" @@ -151,7 +152,7 @@ func New( // InstanceExistAlert is a global warning window indicating an instance already exists. func (s *FrontendQt) InstanceExistAlert() { log.Warn("Instance already exists") - s.QtSetupCoreAndControls() + qtcommon.QtSetupCoreAndControls(s.programName, s.programVer) s.App = widgets.NewQApplication(len(os.Args), os.Args) s.View = qml.NewQQmlApplicationEngine(s.App) s.View.AddImportPath("qrc:///") @@ -283,28 +284,13 @@ func (s *FrontendQt) InvMethod(method string) error { return nil } -// QtSetupCoreAndControls hanldes global setup of Qt. -// Should be called once per program. Probably once per thread is fine. -func (s *FrontendQt) QtSetupCoreAndControls() { - installMessageHandler() - // Core setup. - core.QCoreApplication_SetApplicationName(s.programName) - core.QCoreApplication_SetApplicationVersion(s.programVer) - // High DPI scaling for windows. - core.QCoreApplication_SetAttribute(core.Qt__AA_EnableHighDpiScaling, false) - // Software OpenGL: to avoid dedicated GPU. - core.QCoreApplication_SetAttribute(core.Qt__AA_UseSoftwareOpenGL, true) - // Basic style for QuickControls2 objects. - //quickcontrols2.QQuickStyle_SetStyle("material") -} - // qtExecute is the main function for starting the Qt application. // // It is better to have just one Qt application per program (at least per same // thread). This functions reads the main user interface defined in QML files. // The files are appended to library by Qt-QRC. func (s *FrontendQt) qtExecute(Procedure func(*FrontendQt) error) error { - s.QtSetupCoreAndControls() + qtcommon.QtSetupCoreAndControls(s.programName, s.programVer) s.App = widgets.NewQApplication(len(os.Args), os.Args) if runtime.GOOS == "linux" { // Fix default font. s.App.SetFont(gui.NewQFont2(FcMatchSans(), 12, int(gui.QFont__Normal), false), "") @@ -624,7 +610,7 @@ func (s *FrontendQt) StartUpdate() { defer s.panicHandler.HandlePanic() for current := range progress { s.Qml.SetProgress(current.Processed) - s.Qml.SetProgressDescription(current.Description) + s.Qml.SetProgressDescription(strconv.Itoa(current.Description)) // Error happend if current.Err != nil { log.Error("update progress: ", current.Err) diff --git a/internal/frontend/qt/resources.qrc b/internal/frontend/qt/resources.qrc deleted file mode 100644 index a7c05d71..00000000 --- a/internal/frontend/qt/resources.qrc +++ /dev/null @@ -1,77 +0,0 @@ - - - - - ../qml/ProtonUI/qmldir - ../qml/ProtonUI/AccessibleButton.qml - ../qml/ProtonUI/AccessibleText.qml - ../qml/ProtonUI/AccessibleSelectableText.qml - ../qml/ProtonUI/AccountView.qml - ../qml/ProtonUI/AddAccountBar.qml - ../qml/ProtonUI/BubbleNote.qml - ../qml/ProtonUI/BugReportWindow.qml - ../qml/ProtonUI/ButtonIconText.qml - ../qml/ProtonUI/ButtonRounded.qml - ../qml/ProtonUI/CheckBoxLabel.qml - ../qml/ProtonUI/ClickIconText.qml - ../qml/ProtonUI/Dialog.qml - ../qml/ProtonUI/DialogAddUser.qml - ../qml/ProtonUI/DialogUpdate.qml - ../qml/ProtonUI/DialogConnectionTroubleshoot.qml - ../qml/ProtonUI/FileAndFolderSelect.qml - ../qml/ProtonUI/InformationBar.qml - ../qml/ProtonUI/InputField.qml - ../qml/ProtonUI/InstanceExistsWindow.qml - ../qml/ProtonUI/LogoHeader.qml - ../qml/ProtonUI/PopupMessage.qml - ../qml/ProtonUI/Style.qml - ../qml/ProtonUI/TabButton.qml - ../qml/ProtonUI/TabLabels.qml - ../qml/ProtonUI/TextLabel.qml - ../qml/ProtonUI/TextValue.qml - ../qml/ProtonUI/TLSCertPinIssueBar.qml - ../qml/ProtonUI/WindowTitleBar.qml - ../share/fontawesome-webfont.ttf - - - ../share/icons/rounded-systray.png - ../share/icons/rounded-syswarn.png - ../share/icons/rounded-syswarn.png - ../share/icons/white-systray.png - ../share/icons/white-syswarn.png - ../share/icons/white-syserror.png - ../share/icons/rounded-app.png - ../share/icons/pm_logo.png - ../share/icons/win10_Dash.png - ../share/icons/win10_Times.png - ../share/icons/macos_gray.png - ../share/icons/macos_red.png - ../share/icons/macos_red_hl.png - ../share/icons/macos_red_dark.png - ../share/icons/macos_yellow.png - ../share/icons/macos_yellow_hl.png - ../share/icons/macos_yellow_dark.png - - - ../qml/BridgeUI/qmldir - ../qml/BridgeUI/AccountDelegate.qml - ../qml/BridgeUI/BubbleMenu.qml - ../qml/BridgeUI/Credits.qml - ../qml/BridgeUI/DialogFirstStart.qml - ../qml/BridgeUI/DialogPortChange.qml - ../qml/BridgeUI/DialogYesNo.qml - ../qml/BridgeUI/DialogTLSCertInfo.qml - ../qml/BridgeUI/HelpView.qml - ../qml/BridgeUI/InfoWindow.qml - ../qml/BridgeUI/MainWindow.qml - ../qml/BridgeUI/ManualWindow.qml - ../qml/BridgeUI/OutgoingNoEncPopup.qml - ../qml/BridgeUI/SettingsView.qml - ../qml/BridgeUI/StatusFooter.qml - ../qml/BridgeUI/VersionInfo.qml - - - ../qml/Gui.qml - - - diff --git a/internal/frontend/qt/ui.go b/internal/frontend/qt/ui.go index bd8a0b4e..30cffc26 100644 --- a/internal/frontend/qt/ui.go +++ b/internal/frontend/qt/ui.go @@ -64,7 +64,7 @@ type GoQMLInterface struct { _ string `property:"genericErrSeeLogs"` _ float32 `property:"progress"` - _ int `property:"progressDescription"` + _ string `property:"progressDescription"` _ func(isAvailable bool) `signal:"setConnectionStatus"` _ func(updateState string) `signal:"setUpdateState"` diff --git a/internal/frontend/resources.qrc b/internal/frontend/resources.qrc new file mode 100644 index 00000000..dff9f49f --- /dev/null +++ b/internal/frontend/resources.qrc @@ -0,0 +1,116 @@ + + + + + ./qml/ProtonUI/qmldir + ./qml/ProtonUI/AccessibleButton.qml + ./qml/ProtonUI/AccessibleText.qml + ./qml/ProtonUI/AccessibleSelectableText.qml + ./qml/ProtonUI/AccountView.qml + ./qml/ProtonUI/AddAccountBar.qml + ./qml/ProtonUI/BubbleNote.qml + ./qml/ProtonUI/BugReportWindow.qml + ./qml/ProtonUI/ButtonIconText.qml + ./qml/ProtonUI/ButtonRounded.qml + ./qml/ProtonUI/CheckBoxLabel.qml + ./qml/ProtonUI/ClickIconText.qml + ./qml/ProtonUI/Dialog.qml + ./qml/ProtonUI/DialogAddUser.qml + ./qml/ProtonUI/DialogUpdate.qml + ./qml/ProtonUI/DialogConnectionTroubleshoot.qml + ./qml/ProtonUI/FileAndFolderSelect.qml + ./qml/ProtonUI/InfoToolTip.qml + ./qml/ProtonUI/InformationBar.qml + ./qml/ProtonUI/InputBox.qml + ./qml/ProtonUI/InputField.qml + ./qml/ProtonUI/InstanceExistsWindow.qml + ./qml/ProtonUI/LogoHeader.qml + ./qml/ProtonUI/PopupMessage.qml + ./qml/ProtonUI/RoundedRectangle.qml + ./qml/ProtonUI/Style.qml + ./qml/ProtonUI/TabButton.qml + ./qml/ProtonUI/TabLabels.qml + ./qml/ProtonUI/TextLabel.qml + ./qml/ProtonUI/TextValue.qml + ./qml/ProtonUI/TLSCertPinIssueBar.qml + ./qml/ProtonUI/WindowTitleBar.qml + ./share/fontawesome-webfont.ttf + + + ./share/icons/rounded-systray.png + ./share/icons/rounded-syswarn.png + ./share/icons/rounded-syswarn.png + ./share/icons/white-systray.png + ./share/icons/white-syswarn.png + ./share/icons/white-syserror.png + ./share/icons/rounded-app.png + ./share/icons/pm_logo.png + ./share/icons/win10_Dash.png + ./share/icons/win10_Times.png + ./share/icons/macos_gray.png + ./share/icons/macos_red.png + ./share/icons/macos_red_hl.png + ./share/icons/macos_red_dark.png + ./share/icons/macos_yellow.png + ./share/icons/macos_yellow_hl.png + ./share/icons/macos_yellow_dark.png + + + ./qml/BridgeUI/qmldir + ./qml/BridgeUI/AccountDelegate.qml + ./qml/BridgeUI/BubbleMenu.qml + ./qml/BridgeUI/Credits.qml + ./qml/BridgeUI/DialogFirstStart.qml + ./qml/BridgeUI/DialogPortChange.qml + ./qml/BridgeUI/DialogYesNo.qml + ./qml/BridgeUI/DialogTLSCertInfo.qml + ./qml/BridgeUI/HelpView.qml + ./qml/BridgeUI/InfoWindow.qml + ./qml/BridgeUI/MainWindow.qml + ./qml/BridgeUI/ManualWindow.qml + ./qml/BridgeUI/OutgoingNoEncPopup.qml + ./qml/BridgeUI/SettingsView.qml + ./qml/BridgeUI/StatusFooter.qml + ./qml/BridgeUI/VersionInfo.qml + + + ./qml/ImportExportUI/qmldir + ./qml/ImportExportUI/AccountDelegate.qml + ./qml/ImportExportUI/Credits.qml + ./qml/ImportExportUI/DateBox.qml + ./qml/ImportExportUI/DateInput.qml + ./qml/ImportExportUI/DateRange.qml + ./qml/ImportExportUI/DateRangeMenu.qml + ./qml/ImportExportUI/DateRangeFunctions.qml + ./qml/ImportExportUI/DialogExport.qml + ./qml/ImportExportUI/DialogImport.qml + ./qml/ImportExportUI/DialogYesNo.qml + ./qml/ImportExportUI/ExportStructure.qml + ./qml/ImportExportUI/FilterStructure.qml + ./qml/ImportExportUI/FolderRowButton.qml + ./qml/ImportExportUI/HelpView.qml + ./qml/ImportExportUI/IEStyle.qml + ./qml/ImportExportUI/ImportDelegate.qml + ./qml/ImportExportUI/ImportReport.qml + ./qml/ImportExportUI/ImportReportCell.qml + ./qml/ImportExportUI/ImportSourceButton.qml + ./qml/ImportExportUI/ImportStructure.qml + ./qml/ImportExportUI/InlineDateRange.qml + ./qml/ImportExportUI/InlineLabelSelect.qml + ./qml/ImportExportUI/LabelIconList.qml + ./qml/ImportExportUI/MainWindow.qml + ./qml/ImportExportUI/OutputFormat.qml + ./qml/ImportExportUI/PopupEditFolder.qml + ./qml/ImportExportUI/SelectFolderMenu.qml + ./qml/ImportExportUI/SelectLabelsMenu.qml + ./qml/ImportExportUI/SettingsView.qml + ./qml/ImportExportUI/VersionInfo.qml + ./share/icons/folder_open.png + ./share/icons/envelope_open.png + + + ./qml/Gui.qml + ./qml/GuiIE.qml + + + diff --git a/internal/frontend/share/icons/envelope_open.png b/internal/frontend/share/icons/envelope_open.png new file mode 100644 index 0000000000000000000000000000000000000000..be8d245540d922e5551cf9a118f325318ba1ed12 GIT binary patch literal 22900 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 literal 0 HcmV?d00001 diff --git a/internal/frontend/share/icons/folder_open.png b/internal/frontend/share/icons/folder_open.png new file mode 100644 index 0000000000000000000000000000000000000000..4cc1694a170fb53938685e1878b946f241b2ef08 GIT binary patch literal 8875 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 ip!e-lGs1Q{=e- literal 0 HcmV?d00001 diff --git a/internal/frontend/share/icons/ie.icns b/internal/frontend/share/icons/ie.icns new file mode 100644 index 0000000000000000000000000000000000000000..6b67c5582191aaba2f73f077d614fed337fc6b4b GIT binary patch literal 272787 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%0Du4tBqj7j2QzU! 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 literal 0 HcmV?d00001 diff --git a/internal/frontend/share/icons/ie.ico b/internal/frontend/share/icons/ie.ico new file mode 100644 index 0000000000000000000000000000000000000000..375b1b49fb5ce1dbd924232ab6bbec24e65b5687 GIT binary patch literal 569990 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 + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/frontend/types/types.go b/internal/frontend/types/types.go index ee180249..f5781421 100644 --- a/internal/frontend/types/types.go +++ b/internal/frontend/types/types.go @@ -112,6 +112,8 @@ type ImportExporter interface { GetRemoteImporter(string, string, string, string, string) (*transfer.Transfer, error) GetEMLExporter(string, string) (*transfer.Transfer, error) GetMBOXExporter(string, string) (*transfer.Transfer, error) + SetCurrentOS(os string) + ReportBug(osType, osVersion, description, accountName, address, emailClient string) error } type importExportWrap struct { diff --git a/internal/importexport/credits.go b/internal/importexport/credits.go index b3a2a91d..87eed1d7 100644 --- a/internal/importexport/credits.go +++ b/internal/importexport/credits.go @@ -15,8 +15,8 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -// Code generated by ./credits.sh at Thu Jun 4 15:54:31 CEST 2020. DO NOT EDIT. +// Code generated by ./credits.sh at Thu 04 Jun 2020 04:19:16 PM CEST. DO NOT EDIT. package importexport -const Credits = "github.com/0xAX/notificator;github.com/ProtonMail/bcrypt;github.com/ProtonMail/crypto;github.com/ProtonMail/docker-credential-helpers;github.com/ProtonMail/go-appdir;github.com/ProtonMail/go-apple-mobileconfig;github.com/ProtonMail/go-autostart;github.com/ProtonMail/go-imap-id;github.com/ProtonMail/go-smtp;github.com/ProtonMail/go-vcard;github.com/ProtonMail/gopenpgp;github.com/abiosoft/ishell;github.com/abiosoft/readline;github.com/allan-simon/go-singleinstance;github.com/andybalholm/cascadia;github.com/certifi/gocertifi;github.com/chzyer/logex;github.com/chzyer/test;github.com/cucumber/godog;github.com/docker/docker-credential-helpers;github.com/emersion/go-imap;github.com/emersion/go-imap-appendlimit;github.com/emersion/go-imap-idle;github.com/emersion/go-imap-move;github.com/emersion/go-imap-quota;github.com/emersion/go-imap-specialuse;github.com/emersion/go-imap-unselect;github.com/emersion/go-mbox;github.com/emersion/go-sasl;github.com/emersion/go-smtp;github.com/emersion/go-textwrapper;github.com/emersion/go-vcard;github.com/fatih/color;github.com/flynn-archive/go-shlex;github.com/getsentry/raven-go;github.com/go-resty/resty/v2;github.com/golang/mock;github.com/google/go-cmp;github.com/gopherjs/gopherjs;github.com/hashicorp/go-multierror;github.com/jameskeane/bcrypt;github.com/jaytaylor/html2text;github.com/jhillyerd/enmime;github.com/kardianos/osext;github.com/keybase/go-keychain;github.com/logrusorgru/aurora;github.com/miekg/dns;github.com/myesui/uuid;github.com/nsf/jsondiff;github.com/pkg/errors;github.com/sirupsen/logrus;github.com/skratchdot/open-golang;github.com/stretchr/testify;github.com/therecipe/qt;github.com/twinj/uuid;github.com/urfave/cli;go.etcd.io/bbolt;golang.org/x/crypto;golang.org/x/net;golang.org/x/text;gopkg.in/stretchr/testify.v1;;Font Awesome 4.7.0;;Qt 5.13 by Qt group;" +const Credits = "github.com/0xAX/notificator;github.com/abiosoft/ishell;github.com/abiosoft/readline;github.com/allan-simon/go-singleinstance;github.com/andybalholm/cascadia;github.com/certifi/gocertifi;github.com/chzyer/logex;github.com/chzyer/test;github.com/cucumber/godog;github.com/docker/docker-credential-helpers;github.com/emersion/go-imap;github.com/emersion/go-imap-appendlimit;github.com/emersion/go-imap-idle;github.com/emersion/go-imap-move;github.com/emersion/go-imap-quota;github.com/emersion/go-imap-specialuse;github.com/emersion/go-imap-unselect;github.com/emersion/go-mbox;github.com/emersion/go-sasl;github.com/emersion/go-smtp;github.com/emersion/go-textwrapper;github.com/emersion/go-vcard;github.com/fatih/color;github.com/flynn-archive/go-shlex;github.com/getsentry/raven-go;github.com/golang/mock;github.com/google/go-cmp;github.com/gopherjs/gopherjs;github.com/go-resty/resty/v2;github.com/hashicorp/go-multierror;github.com/jameskeane/bcrypt;github.com/jaytaylor/html2text;github.com/jhillyerd/enmime;github.com/kardianos/osext;github.com/keybase/go-keychain;github.com/logrusorgru/aurora;github.com/miekg/dns;github.com/myesui/uuid;github.com/nsf/jsondiff;github.com/pkg/errors;github.com/ProtonMail/bcrypt;github.com/ProtonMail/crypto;github.com/ProtonMail/docker-credential-helpers;github.com/ProtonMail/go-appdir;github.com/ProtonMail/go-apple-mobileconfig;github.com/ProtonMail/go-autostart;github.com/ProtonMail/go-imap-id;github.com/ProtonMail/gopenpgp;github.com/ProtonMail/go-smtp;github.com/ProtonMail/go-vcard;github.com/sirupsen/logrus;github.com/skratchdot/open-golang;github.com/stretchr/testify;github.com/therecipe/qt;github.com/twinj/uuid;github.com/urfave/cli;go.etcd.io/bbolt;golang.org/x/crypto;golang.org/x/net;golang.org/x/text;gopkg.in/stretchr/testify.v1;;Font Awesome 4.7.0;;Qt 5.13 by Qt group;" diff --git a/internal/importexport/importexport.go b/internal/importexport/importexport.go index 569ce733..ceaf7176 100644 --- a/internal/importexport/importexport.go +++ b/internal/importexport/importexport.go @@ -55,6 +55,30 @@ func New( } } +// ReportBug reports a new bug from the user. +func (ie *ImportExport) ReportBug(osType, osVersion, description, accountName, address, emailClient string) error { + c := ie.clientManager.GetAnonymousClient() + defer c.Logout() + + title := "[Import-Export] Bug" + if err := c.ReportBugWithEmailClient( + osType, + osVersion, + title, + description, + accountName, + address, + emailClient, + ); err != nil { + log.Error("Reporting bug failed: ", err) + return err + } + + log.Info("Bug successfully reported") + + return nil +} + // GetLocalImporter returns transferrer from local EML or MBOX structure to ProtonMail account. func (ie *ImportExport) GetLocalImporter(address, path string) (*transfer.Transfer, error) { source := transfer.NewLocalProvider(path) @@ -111,3 +135,6 @@ func (ie *ImportExport) getPMAPIProvider(address string) (*transfer.PMAPIProvide return transfer.NewPMAPIProvider(ie.clientManager, user.ID(), addressID) } + +// SetCurrentOS TODO +func (ie *ImportExport) SetCurrentOS(os string) {} diff --git a/internal/store/user_mailbox.go b/internal/store/user_mailbox.go index db8e9b9b..6a0d3bde 100644 --- a/internal/store/user_mailbox.go +++ b/internal/store/user_mailbox.go @@ -110,22 +110,14 @@ func (store *Store) leastUsedColor() string { store.lock.RLock() defer store.lock.RUnlock() - usage := map[string]int{} + colors := []string{} for _, a := range store.addresses { for _, m := range a.mailboxes { - if m.color != "" { - usage[m.color]++ - } + colors = append(colors, m.color) } } - leastUsed := pmapi.LabelColors[0] - for _, color := range pmapi.LabelColors { - if usage[leastUsed] > usage[color] { - leastUsed = color - } - } - return leastUsed + return pmapi.LeastUsedColor(colors) } // updateMailbox updates the mailbox via the API. diff --git a/internal/transfer/mailbox.go b/internal/transfer/mailbox.go index 556c3218..b4fbaf46 100644 --- a/internal/transfer/mailbox.go +++ b/internal/transfer/mailbox.go @@ -21,6 +21,8 @@ import ( "crypto/sha256" "fmt" "strings" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" ) // Mailbox is universal data holder of mailbox details for every provider. @@ -36,6 +38,19 @@ func (m Mailbox) Hash() string { return fmt.Sprintf("%x", sha256.Sum256([]byte(m.Name))) } +// LeastUsedColor is intended to return color for creating a new inbox or label +func LeastUsedColor(mailboxes []Mailbox) string { + usedColors := []string{} + + if mailboxes != nil { + for _, m := range mailboxes { + usedColors = append(usedColors, m.Color) + } + } + + return pmapi.LeastUsedColor(usedColors) +} + // findMatchingMailboxes returns all matching mailboxes from `mailboxes`. // Only one exclusive mailbox is returned. func (m Mailbox) findMatchingMailboxes(mailboxes []Mailbox) []Mailbox { diff --git a/internal/transfer/mailbox_test.go b/internal/transfer/mailbox_test.go index c59b4581..5865cfdc 100644 --- a/internal/transfer/mailbox_test.go +++ b/internal/transfer/mailbox_test.go @@ -23,6 +23,49 @@ import ( r "github.com/stretchr/testify/require" ) +func TestLeastUsedColor(t *testing.T) { + var mailboxes []Mailbox + // Unset mailboxes, should use first available color + mailboxes = nil + r.Equal(t, "#7272a7", LeastUsedColor(mailboxes)) + + // No mailboxes at all, should use first available color + mailboxes = []Mailbox{} + r.Equal(t, "#7272a7", LeastUsedColor(mailboxes)) + + // All colors have same frequency, should use first available color + mailboxes = []Mailbox{ + {Name: "Mbox1", Color: "#7272a7"}, + {Name: "Mbox2", Color: "#cf5858"}, + {Name: "Mbox3", Color: "#c26cc7"}, + {Name: "Mbox4", Color: "#7569d1"}, + {Name: "Mbox5", Color: "#69a9d1"}, + {Name: "Mbox6", Color: "#5ec7b7"}, + {Name: "Mbox7", Color: "#72bb75"}, + {Name: "Mbox8", Color: "#c3d261"}, + {Name: "Mbox9", Color: "#e6c04c"}, + {Name: "Mbox10", Color: "#e6984c"}, + {Name: "Mbox11", Color: "#8989ac"}, + {Name: "Mbox12", Color: "#cf7e7e"}, + {Name: "Mbox13", Color: "#c793ca"}, + {Name: "Mbox14", Color: "#9b94d1"}, + {Name: "Mbox15", Color: "#a8c4d5"}, + {Name: "Mbox16", Color: "#97c9c1"}, + {Name: "Mbox17", Color: "#9db99f"}, + {Name: "Mbox18", Color: "#c6cd97"}, + {Name: "Mbox19", Color: "#e7d292"}, + {Name: "Mbox20", Color: "#dfb286"}, + } + r.Equal(t, "#7272a7", LeastUsedColor(mailboxes)) + + // First three colors already used, but others wasn't. Should use first non-used one. + mailboxes = []Mailbox{ + {Name: "Mbox1", Color: "#7272a7"}, + {Name: "Mbox2", Color: "#cf5858"}, + {Name: "Mbox3", Color: "#c26cc7"}, + } + r.Equal(t, "#7569d1", LeastUsedColor(mailboxes)) +} func TestFindMatchingMailboxes(t *testing.T) { mailboxes := []Mailbox{ {Name: "Inbox", IsExclusive: true}, diff --git a/internal/transfer/transfer.go b/internal/transfer/transfer.go index bcd7290d..2fda8f7e 100644 --- a/internal/transfer/transfer.go +++ b/internal/transfer/transfer.go @@ -141,8 +141,8 @@ func (t *Transfer) CreateTargetMailbox(mailbox Mailbox) (Mailbox, error) { return t.target.CreateMailbox(mailbox) } -// ChangeTarget allows to change target. Ideally should not be used. -// Useful for situration after user changes mind where to export files and similar. +// ChangeTarget changes the target. It is safe to change target for export, +// must not be changed for import. Do not set after you started transfer. func (t *Transfer) ChangeTarget(target TargetProvider) { t.target = target } diff --git a/pkg/pmapi/labels.go b/pkg/pmapi/labels.go index eee5fc86..cc970740 100644 --- a/pkg/pmapi/labels.go +++ b/pkg/pmapi/labels.go @@ -175,3 +175,21 @@ func (c *client) DeleteLabel(id string) (err error) { err = res.Err() return } + +// LeastUsedColor is intended to return color for creating a new inbox or label +func LeastUsedColor(colors []string) (color string) { + color = LabelColors[0] + frequency := map[string]int{} + + for _, c := range colors { + frequency[c]++ + } + + for _, c := range LabelColors { + if frequency[color] > frequency[c] { + color = c + } + } + + return +} diff --git a/pkg/pmapi/labels_test.go b/pkg/pmapi/labels_test.go index 6479f501..36096a57 100644 --- a/pkg/pmapi/labels_test.go +++ b/pkg/pmapi/labels_test.go @@ -24,6 +24,8 @@ import ( "net/http" "reflect" "testing" + + r "github.com/stretchr/testify/require" ) const testLabelsBody = `{ @@ -184,3 +186,17 @@ func TestClient_DeleteLabel(t *testing.T) { t.Fatal("Expected no error while deleting label, got:", err) } } + +func TestLeastUsedColor(t *testing.T) { + // No colors at all, should use first available color + colors := []string{} + r.Equal(t, "#7272a7", LeastUsedColor(colors)) + + // All colors have same frequency, should use first available color + colors = []string{"#7272a7", "#cf5858", "#c26cc7", "#7569d1", "#69a9d1", "#5ec7b7", "#72bb75", "#c3d261", "#e6c04c", "#e6984c", "#8989ac", "#cf7e7e", "#c793ca", "#9b94d1", "#a8c4d5", "#97c9c1", "#9db99f", "#c6cd97", "#e7d292", "#dfb286"} + r.Equal(t, "#7272a7", LeastUsedColor(colors)) + + // First three colors already used, but others wasn't. Should use first non-used one. + colors = []string{"#7272a7", "#cf5858", "#c26cc7"} + r.Equal(t, "#7569d1", LeastUsedColor(colors)) +} diff --git a/utils/credits.sh b/utils/credits.sh index 6827c7b0..4c9ccf9f 100755 --- a/utils/credits.sh +++ b/utils/credits.sh @@ -23,14 +23,14 @@ PACKAGE=$1 # Vendor packages LOCKFILE=../go.mod -egrep $'^\t[^=>]*$' $LOCKFILE | sed -r 's/\t([^ ]*) v.*/\1/g' > tmp1 -egrep $'^\t.*=>.*v.*$' $LOCKFILE | sed -r 's/^.*=> ([^ ]*)( v.*)?/\1/g' >> tmp1 -cat tmp1 | egrep -v 'therecipe/qt/internal|therecipe/env_.*_512|protontech' | sort | uniq > tmp +egrep $'^\t[^=>]*$' $LOCKFILE | sed -r 's/\t([^ ]*) v.*/\1/g' > tmp1-$PACKAGE +egrep $'^\t.*=>.*v.*$' $LOCKFILE | sed -r 's/^.*=> ([^ ]*)( v.*)?/\1/g' >> tmp1-$PACKAGE +cat tmp1-$PACKAGE | egrep -v 'therecipe/qt/internal|therecipe/env_.*_512|protontech' | sort | uniq > tmp-$PACKAGE # Add non vendor credits -echo -e "\nFont Awesome 4.7.0\n\nQt 5.13 by Qt group\n" >> tmp +echo -e "\nFont Awesome 4.7.0\n\nQt 5.13 by Qt group\n" >> tmp-$PACKAGE # join lines -sed -i -e ':a' -e 'N' -e '$!ba' -e 's|\n|;|g' tmp +sed -i -e ':a' -e 'N' -e '$!ba' -e 's|\n|;|g' tmp-$PACKAGE cat ../utils/license_header.txt > ../internal/$PACKAGE/credits.go -echo -e '// Code generated by '`echo $0`' at '`date`'. DO NOT EDIT.\n\npackage '$PACKAGE'\n\nconst Credits = "'$(cat tmp)'"' >> ../internal/$PACKAGE/credits.go -rm tmp1 tmp +echo -e '// Code generated by '`echo $0`' at '`date`'. DO NOT EDIT.\n\npackage '$PACKAGE'\n\nconst Credits = "'$(cat tmp-$PACKAGE)'"' >> ../internal/$PACKAGE/credits.go +rm tmp1-$PACKAGE tmp-$PACKAGE diff --git a/utils/enums.sh b/utils/enums.sh new file mode 100644 index 00000000..b9431abd --- /dev/null +++ b/utils/enums.sh @@ -0,0 +1,149 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +#!/bin/bash + +# create QML JSON object from list of golang constants +# run this script and output line stored in `out.qml` insert to `Gui.qml` + +list=" +qtfrontend.PathOK +qtfrontend.PathEmptyPath +qtfrontend.PathWrongPath +qtfrontend.PathNotADir +qtfrontend.PathWrongPermissions +qtfrontend.PathDirEmpty + +errors.ErrUnknownError +errors.ErrEventAPILogout +errors.ErrUpgradeAPI +errors.ErrUpgradeJSON +errors.ErrUserAuth +errors.ErrQApplication +errors.ErrEmailExportFailed +errors.ErrEmailExportMissing +errors.ErrNothingToImport +errors.ErrEmailImportFailed +errors.ErrDraftImportFailed +errors.ErrDraftLabelFailed +errors.ErrEncryptMessageAttachment +errors.ErrEncryptMessage +errors.ErrNoInternetWhileImport +errors.ErrUnlockUser +errors.ErrSourceMessageNotSelected + +source.ErrCannotParseMail +source.ErrWrongLoginOrPassword +source.ErrWrongServerPathOrPort +source.ErrWrongAuthMethod +source.ErrIMAPFetchFailed + +qtfrontend.ErrLocalSourceLoadFailed +qtfrontend.ErrPMLoadFailed +qtfrontend.ErrRemoteSourceLoadFailed +qtfrontend.ErrLoadAccountList +qtfrontend.ErrExit +qtfrontend.ErrRetry +qtfrontend.ErrAsk +qtfrontend.ErrImportFailed +qtfrontend.ErrCreateLabelFailed +qtfrontend.ErrCreateFolderFailed +qtfrontend.ErrUpdateLabelFailed +qtfrontend.ErrUpdateFolderFailed +qtfrontend.ErrFillFolderName +qtfrontend.ErrSelectFolderColor +qtfrontend.ErrNoInternet + +qtfrontend.FolderTypeSystem +qtfrontend.FolderTypeLabel +qtfrontend.FolderTypeFolder +qtfrontend.FolderTypeExternal + +backend.ProgressInit +backend.ProgressLooping +backend.ErrPMAPIMessageTooLarge + +qtfrontend.StatusNoInternet +qtfrontend.StatusCheckingInternet +qtfrontend.StatusNewVersionAvailable +qtfrontend.StatusUpToDate +qtfrontend.StatusForceUpgrade +" + +first=true + + +if true; then + echo '// +build ignore' + echo '' + echo 'package main' + echo '' + echo 'import (' + echo ' "github.com/ProtonMail/Import-Export/backend"' + echo ' "github.com/ProtonMail/Import-Export/backend/source"' + echo ' "github.com/ProtonMail/Import-Export/backend/errors"' + echo ' "github.com/ProtonMail/Import-Export/frontend"' + echo ' "fmt"' + echo ')' + echo '' + echo 'func main(){' + echo ' checkValues := map[int]string{}' + echo ' checkDuplicates := map[string]bool{}' + echo ' fmt.Print("{")' + for c in $list + do + if ! $first; then + echo 'fmt.Print(",")' + fi + + if [[ $c =~ .*Err ]]; then + ## Add check that all Err have different value + echo 'if enumName,ok := checkValues[int('$c')]; ok {' + echo ' panic("Enum '$c' and "+enumName+" has same value")' + echo '}' + echo 'checkValues[int('$c')]="'$c'"' + fi + + cname=`echo $c | cut -d. -f2` + lowCase=${cname,} + + ## Add check that all qml enums have different value + echo 'if checkDuplicates["'$lowCase'"]{' + echo ' panic("Enum with same lowcase name as '$c' has already been registered")' + echo '}' + echo 'checkDuplicates["'$lowCase'"]=true' + + ## add value in lowercase + echo 'fmt.Printf("\"'$lowCase'\":%#v",'$c')' + + first=false + done + echo ' fmt.Print("}")' + echo '}' +fi > main.go + + +if true; then +echo -n "property var enums : JSON.parse('" +go run main.go || exit 5 +echo -n "')" +fi > out.qml + +rm main.go +sed -i "s/property var enums : JSON.parse.*$/`cat out.qml`/" ./qml/Gui.qml +rm out.qml + From 1c10cc50650ac976d40530f87b0bd1a599c20b14 Mon Sep 17 00:00:00 2001 From: Michal Horejsek Date: Wed, 17 Jun 2020 15:29:41 +0200 Subject: [PATCH 03/22] Import/Export backend --- .gitlab-ci.yml | 62 +++- Makefile | 7 +- README.md | 1 + cmd/Desktop-Bridge/main.go | 214 +++----------- cmd/Import-Export/main.go | 194 +++---------- go.mod | 1 + {pkg/args => internal/cmd}/args.go | 6 +- internal/cmd/main.go | 86 ++++++ internal/cmd/memory_profile.go | 43 +++ internal/cmd/restart.go | 108 +++++++ internal/cmd/version_file.go | 32 +++ internal/frontend/cli-ie/frontend.go | 8 +- internal/frontend/cli-ie/updates.go | 6 +- internal/frontend/cli-ie/utils.go | 4 +- internal/frontend/cli/updates.go | 2 +- internal/frontend/qt-common/path_status.go | 4 +- internal/importexport/credits.go | 4 +- internal/importexport/release_notes.go | 2 +- internal/transfer/mailbox.go | 8 +- internal/transfer/message.go | 4 + internal/transfer/progress.go | 8 +- internal/transfer/progress_test.go | 4 +- internal/transfer/provider_imap_source.go | 19 +- internal/transfer/provider_imap_utils.go | 85 ++++-- internal/transfer/provider_mbox_target.go | 2 +- internal/transfer/provider_pmapi.go | 8 +- internal/transfer/provider_pmapi_source.go | 13 +- internal/transfer/provider_pmapi_target.go | 122 ++++++-- internal/transfer/provider_pmapi_test.go | 17 +- internal/transfer/provider_pmapi_utils.go | 3 +- internal/transfer/transfer_test.go | 8 +- pkg/constants/constants.go | 3 - pkg/message/build.go | 2 +- pkg/pmapi/clientmanager.go | 7 +- pkg/updates/updates.go | 52 ++-- pkg/updates/updates_test.go | 8 +- test/Makefile | 33 ++- test/api_actions_test.go | 45 +++ test/api_checks_test.go | 40 +++ test/api_setup_test.go | 31 ++ test/bdd_test.go | 32 ++- test/benchmarks/bench_test.go | 2 +- test/bridge_actions_test.go | 123 +------- test/common_checks_test.go | 37 +++ test/context/bridge.go | 13 +- test/context/config.go | 3 + test/context/context.go | 41 ++- test/context/importexport.go | 48 ++++ test/context/pmapi_controller.go | 1 + test/context/transfer.go | 91 ++++++ test/context/{bridge_user.go => users.go} | 20 +- test/fakeapi/controller_control.go | 14 + test/fakeapi/keyring_userKey | 62 ++++ test/fakeapi/messages.go | 59 ++-- test/features/{ => bridge}/imap/auth.feature | 6 +- .../{ => bridge}/imap/idle/basic.feature | 0 .../{ => bridge}/imap/idle/two_users.feature | 0 .../{ => bridge}/imap/mailbox/create.feature | 0 .../{ => bridge}/imap/mailbox/delete.feature | 0 .../{ => bridge}/imap/mailbox/info.feature | 0 .../{ => bridge}/imap/mailbox/list.feature | 0 .../{ => bridge}/imap/mailbox/rename.feature | 0 .../{ => bridge}/imap/mailbox/select.feature | 0 .../{ => bridge}/imap/mailbox/status.feature | 0 .../{ => bridge}/imap/message/copy.feature | 0 .../{ => bridge}/imap/message/create.feature | 0 .../{ => bridge}/imap/message/delete.feature | 0 .../{ => bridge}/imap/message/fetch.feature | 0 .../{ => bridge}/imap/message/import.feature | 0 .../{ => bridge}/imap/message/move.feature | 0 .../{ => bridge}/imap/message/search.feature | 0 .../{ => bridge}/imap/message/update.feature | 0 test/features/{ => bridge}/smtp/auth.feature | 0 .../{ => bridge}/smtp/send/bcc.feature | 0 .../{ => bridge}/smtp/send/failures.feature | 0 .../{ => bridge}/smtp/send/html.feature | 0 .../{ => bridge}/smtp/send/html_att.feature | 0 .../{ => bridge}/smtp/send/plain.feature | 0 .../{ => bridge}/smtp/send/plain_att.feature | 0 .../smtp/send/same_message.feature | 0 .../smtp/send/two_messages.feature | 0 .../bridge/{ => users}/addressmode.feature | 4 +- .../delete.feature} | 24 +- .../features/bridge/{ => users}/login.feature | 44 +-- .../bridge/{ => users}/relogin.feature | 18 +- test/features/bridge/{ => users}/sync.feature | 4 +- test/features/ie/transfer/export_eml.feature | 43 +++ test/features/ie/transfer/export_mbox.feature | 43 +++ test/features/ie/transfer/import_eml.feature | 60 ++++ .../ie/transfer/import_export.feature | 49 ++++ test/features/ie/transfer/import_imap.feature | 79 ++++++ test/features/ie/transfer/import_mbox.feature | 64 +++++ test/features/ie/users/delete.feature | 20 ++ test/features/ie/users/login.feature | 56 ++++ test/features/ie/users/relogin.feature | 12 + test/liveapi/messages.go | 28 ++ test/mocks/{imap.go => imap_client.go} | 0 test/mocks/imap_response.go | 4 +- test/mocks/imap_server.go | 227 +++++++++++++++ test/store_checks_test.go | 10 +- test/store_setup_test.go | 10 + test/transfer_actions_test.go | 227 +++++++++++++++ test/transfer_checks_test.go | 267 ++++++++++++++++++ test/transfer_setup_test.go | 261 +++++++++++++++++ test/users_actions_test.go | 125 ++++++++ ...ge_checks_test.go => users_checks_test.go} | 53 ++-- ...idge_setup_test.go => users_setup_test.go} | 22 +- 107 files changed, 2869 insertions(+), 743 deletions(-) rename {pkg/args => internal/cmd}/args.go (90%) create mode 100644 internal/cmd/main.go create mode 100644 internal/cmd/memory_profile.go create mode 100644 internal/cmd/restart.go create mode 100644 internal/cmd/version_file.go create mode 100644 test/api_actions_test.go create mode 100644 test/api_setup_test.go create mode 100644 test/common_checks_test.go create mode 100644 test/context/importexport.go create mode 100644 test/context/transfer.go rename test/context/{bridge_user.go => users.go} (90%) create mode 100644 test/fakeapi/keyring_userKey rename test/features/{ => bridge}/imap/auth.feature (97%) rename test/features/{ => bridge}/imap/idle/basic.feature (100%) rename test/features/{ => bridge}/imap/idle/two_users.feature (100%) rename test/features/{ => bridge}/imap/mailbox/create.feature (100%) rename test/features/{ => bridge}/imap/mailbox/delete.feature (100%) rename test/features/{ => bridge}/imap/mailbox/info.feature (100%) rename test/features/{ => bridge}/imap/mailbox/list.feature (100%) rename test/features/{ => bridge}/imap/mailbox/rename.feature (100%) rename test/features/{ => bridge}/imap/mailbox/select.feature (100%) rename test/features/{ => bridge}/imap/mailbox/status.feature (100%) rename test/features/{ => bridge}/imap/message/copy.feature (100%) rename test/features/{ => bridge}/imap/message/create.feature (100%) rename test/features/{ => bridge}/imap/message/delete.feature (100%) rename test/features/{ => bridge}/imap/message/fetch.feature (100%) rename test/features/{ => bridge}/imap/message/import.feature (100%) rename test/features/{ => bridge}/imap/message/move.feature (100%) rename test/features/{ => bridge}/imap/message/search.feature (100%) rename test/features/{ => bridge}/imap/message/update.feature (100%) rename test/features/{ => bridge}/smtp/auth.feature (100%) rename test/features/{ => bridge}/smtp/send/bcc.feature (100%) rename test/features/{ => bridge}/smtp/send/failures.feature (100%) rename test/features/{ => bridge}/smtp/send/html.feature (100%) rename test/features/{ => bridge}/smtp/send/html_att.feature (100%) rename test/features/{ => bridge}/smtp/send/plain.feature (100%) rename test/features/{ => bridge}/smtp/send/plain_att.feature (100%) rename test/features/{ => bridge}/smtp/send/same_message.feature (100%) rename test/features/{ => bridge}/smtp/send/two_messages.feature (100%) rename test/features/bridge/{ => users}/addressmode.feature (98%) rename test/features/bridge/{deleteuser.feature => users/delete.feature} (62%) rename test/features/bridge/{ => users}/login.feature (59%) rename test/features/bridge/{ => users}/relogin.feature (70%) rename test/features/bridge/{ => users}/sync.feature (97%) create mode 100644 test/features/ie/transfer/export_eml.feature create mode 100644 test/features/ie/transfer/export_mbox.feature create mode 100644 test/features/ie/transfer/import_eml.feature create mode 100644 test/features/ie/transfer/import_export.feature create mode 100644 test/features/ie/transfer/import_imap.feature create mode 100644 test/features/ie/transfer/import_mbox.feature create mode 100644 test/features/ie/users/delete.feature create mode 100644 test/features/ie/users/login.feature create mode 100644 test/features/ie/users/relogin.feature rename test/mocks/{imap.go => imap_client.go} (100%) create mode 100644 test/mocks/imap_server.go create mode 100644 test/transfer_actions_test.go create mode 100644 test/transfer_checks_test.go create mode 100644 test/transfer_setup_test.go create mode 100644 test/users_actions_test.go rename test/{bridge_checks_test.go => users_checks_test.go} (77%) rename test/{bridge_setup_test.go => users_setup_test.go} (89%) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 17bebce5..00ba87a8 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -77,7 +77,6 @@ dependency-updates: build-linux: stage: build - # Test build every time (= we want to know build is possible). only: - branches script: @@ -88,6 +87,18 @@ build-linux: - bridge_*.tgz expire_in: 2 week +build-ie-linux: + stage: build + only: + - branches + script: + - make build-ie + artifacts: + name: "bridge-linux-$CI_COMMIT_SHORT_SHA" + paths: + - bridge_*.tgz + expire_in: 2 week + build-darwin: stage: build only: @@ -114,6 +125,31 @@ build-darwin: - bridge_*.tgz expire_in: 2 week +build-ie-darwin: + stage: build + only: + - branches + before_script: + - eval $(ssh-agent -s) + - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null + - export PATH=/usr/local/bin:$PATH + - export PATH=/usr/local/opt/git/bin:$PATH + - export PATH=/usr/local/opt/make/libexec/gnubin:$PATH + - export PATH=/usr/local/opt/go@1.13/bin:$PATH + - export PATH=/usr/local/opt/gnu-sed/libexec/gnubin:$PATH + - export GOPATH=~/go + - export PATH=$GOPATH/bin:$PATH + cache: {} + tags: + - macOS-bridge + script: + - make build-ie + artifacts: + name: "bridge-darwin-$CI_COMMIT_SHORT_SHA" + paths: + - bridge_*.tgz + expire_in: 2 week + build-windows: stage: build services: @@ -136,6 +172,30 @@ build-windows: - bridge_*.tgz expire_in: 2 week +build-ie-windows: + stage: build + services: + - docker:dind + only: + - branches + variables: + DOCKER_HOST: tcp://docker:2375 + script: + # We need to install docker because qtdeploy builds for windows inside a docker container. + # Docker will connect to the dockerd daemon provided by the runner service docker:dind at tcp://docker:2375. + - curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh + - apt-get update && apt-get -y install binutils-mingw-w64 tar gzip + - ln -s /usr/bin/x86_64-w64-mingw32-windres /usr/bin/windres + - go mod download + - TARGET_OS=windows make build-ie + artifacts: + name: "bridge-windows-$CI_COMMIT_SHORT_SHA" + paths: + - bridge_*.tgz + expire_in: 2 week + +# Stage: MIRROR + mirror-repo: stage: mirror only: diff --git a/Makefile b/Makefile index 0d959d64..56f40189 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,12 @@ TARGET_OS?=${GOOS} ## Build .PHONY: build build-ie build-nogui build-ie-nogui check-has-go -APP_VERSION?=$(shell git describe --abbrev=0 --tags)-git +BRIDGE_APP_VERSION?=1.4.0-git +IE_APP_VERSION?=1.0.0-git +APP_VERSION=${BRIDGE_APP_VERSION} +ifeq "${TARGET_CMD}" "Import-Export" + APP_VERSION=${IE_APP_VERSION} +endif REVISION:=$(shell git rev-parse --short=10 HEAD) BUILD_TIME:=$(shell date +%FT%T%z) diff --git a/README.md b/README.md index 059d018c..f512e1d6 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ or ### Integration testing - `TEST_ENV`: set which env to use (fake or live) +- `TEST_APP`: set which app to test (bridge or ie) - `TEST_ACCOUNTS`: set JSON file with configured accounts - `TAGS`: set build tags for tests - `FEATURES`: set feature dir, file or scenario to test diff --git a/cmd/Desktop-Bridge/main.go b/cmd/Desktop-Bridge/main.go index 1c48218f..0dc459b3 100644 --- a/cmd/Desktop-Bridge/main.go +++ b/cmd/Desktop-Bridge/main.go @@ -35,18 +35,13 @@ package main */ import ( - "fmt" "io/ioutil" "os" - "os/exec" - "path/filepath" - "runtime" "runtime/pprof" - "strconv" - "strings" "github.com/ProtonMail/proton-bridge/internal/api" "github.com/ProtonMail/proton-bridge/internal/bridge" + "github.com/ProtonMail/proton-bridge/internal/cmd" "github.com/ProtonMail/proton-bridge/internal/cookies" "github.com/ProtonMail/proton-bridge/internal/events" "github.com/ProtonMail/proton-bridge/internal/frontend" @@ -54,104 +49,42 @@ import ( "github.com/ProtonMail/proton-bridge/internal/preferences" "github.com/ProtonMail/proton-bridge/internal/smtp" "github.com/ProtonMail/proton-bridge/internal/users/credentials" - "github.com/ProtonMail/proton-bridge/pkg/args" "github.com/ProtonMail/proton-bridge/pkg/config" "github.com/ProtonMail/proton-bridge/pkg/constants" "github.com/ProtonMail/proton-bridge/pkg/listener" "github.com/ProtonMail/proton-bridge/pkg/pmapi" "github.com/ProtonMail/proton-bridge/pkg/updates" "github.com/allan-simon/go-singleinstance" - "github.com/getsentry/raven-go" "github.com/sirupsen/logrus" "github.com/urfave/cli" ) -// cacheVersion is used for cache files such as lock, events, preferences, user_info, db files. -// Different number will drop old files and create new ones. -const cacheVersion = "c11" +const ( + // cacheVersion is used for cache files such as lock, events, preferences, user_info, db files. + // Different number will drop old files and create new ones. + cacheVersion = "c11" + + appName = "bridge" +) var ( log = logrus.WithField("pkg", "main") //nolint[gochecknoglobals] - - // How many crashes in a row. - numberOfCrashes = 0 //nolint[gochecknoglobals] - - // After how many crashes bridge gives up starting. - maxAllowedCrashes = 10 //nolint[gochecknoglobals] ) func main() { - if err := raven.SetDSN(constants.DSNSentry); err != nil { - log.WithError(err).Errorln("Can not setup sentry DSN") - } - raven.SetRelease(constants.Revision) - - args.FilterProcessSerialNumberFromArgs() - filterRestartNumberFromArgs() - - app := cli.NewApp() - app.Name = "Protonmail Bridge" - app.Version = constants.BuildVersion - app.Flags = []cli.Flag{ - cli.StringFlag{ - Name: "log-level, l", - Usage: "Set the log level (one of panic, fatal, error, warn, info, debug, debug-client, debug-server)"}, - cli.BoolFlag{ - Name: "no-window", - Usage: "Don't show window after start"}, - cli.BoolFlag{ - Name: "cli, c", - Usage: "Use command line interface"}, - cli.BoolFlag{ - Name: "noninteractive", - Usage: "Start Bridge entirely noninteractively"}, - cli.StringFlag{ - Name: "version-json, g", - Usage: "Generate json version file"}, - cli.BoolFlag{ - Name: "mem-prof, m", - Usage: "Generate memory profile"}, - cli.BoolFlag{ - Name: "cpu-prof, p", - Usage: "Generate CPU profile"}, - } - app.Usage = "ProtonMail IMAP and SMTP Bridge" - app.Action = run - - // Always log the basic info about current bridge. - logrus.SetLevel(logrus.InfoLevel) - log.WithField("version", constants.Version). - WithField("revision", constants.Revision). - WithField("runtime", runtime.GOOS). - WithField("build", constants.BuildTime). - WithField("args", os.Args). - WithField("appLong", app.Name). - WithField("appShort", constants.AppShortName). - Info("Run app") - if err := app.Run(os.Args); err != nil { - log.Error("Program exited with error: ", err) - } -} - -type panicHandler struct { - cfg *config.Config - err *error // Pointer to error of cli action. -} - -func (ph *panicHandler) HandlePanic() { - r := recover() - if r == nil { - return - } - - config.HandlePanic(ph.cfg, fmt.Sprintf("Recover: %v", r)) - frontend.HandlePanic("ProtonMail Bridge") - - *ph.err = cli.NewExitError("Panic and restart", 255) - numberOfCrashes++ - log.Error("Restarting after panic") - restartApp() - os.Exit(255) + cmd.Main( + "ProtonMail Bridge", + "ProtonMail IMAP and SMTP Bridge", + []cli.Flag{ + cli.BoolFlag{ + Name: "no-window", + Usage: "Don't show window after start"}, + cli.BoolFlag{ + Name: "noninteractive", + Usage: "Start Bridge entirely noninteractively"}, + }, + run, + ) } // run initializes and starts everything in a precise order. @@ -159,13 +92,17 @@ func (ph *panicHandler) HandlePanic() { // IMPORTANT: ***Read the comments before CHANGING the order *** func run(context *cli.Context) (contextError error) { // nolint[funlen] // We need to have config instance to setup a logs, panic handler, etc ... - cfg := config.New(constants.AppShortName, constants.Version, constants.Revision, cacheVersion) + cfg := config.New(appName, constants.Version, constants.Revision, cacheVersion) // We want to know about any problem. Our PanicHandler calls sentry which is // not dependent on anything else. If that fails, it tries to create crash // report which will not be possible if no folder can be created. That's the // only problem we will not be notified about in any way. - panicHandler := &panicHandler{cfg, &contextError} + panicHandler := &cmd.PanicHandler{ + AppName: "ProtonMail Bridge", + Config: cfg, + Err: &contextError, + } defer panicHandler.HandlePanic() // First we need config and create necessary folder; it's dependency for everything. @@ -177,13 +114,6 @@ func run(context *cli.Context) (contextError error) { // nolint[funlen] logLevel := context.GlobalString("log-level") debugClient, debugServer := config.SetupLog(cfg, logLevel) - // Should be called after logs are configured but before preferences are created. - migratePreferencesFromC10(cfg) - - if err := cfg.ClearOldData(); err != nil { - log.Error("Cannot clear old data: ", err) - } - // Doesn't make sense to continue when Bridge was invoked with wrong arguments. // We should tell that to the user before we do anything else. if context.Args().First() != "" { @@ -193,21 +123,16 @@ func run(context *cli.Context) (contextError error) { // nolint[funlen] // It's safe to get version JSON file even when other instance is running. // (thus we put it before check of presence of other Bridge instance). - updates := updates.New( - constants.AppShortName, - constants.Version, - constants.Revision, - constants.BuildTime, - bridge.ReleaseNotes, - bridge.ReleaseFixedBugs, - cfg.GetUpdateDir(), - ) + updates := updates.NewBridge(cfg.GetUpdateDir()) if dir := context.GlobalString("version-json"); dir != "" { - generateVersionFiles(updates, dir) + cmd.GenerateVersionFiles(updates, dir) return nil } + // Should be called after logs are configured but before preferences are created. + migratePreferencesFromC10(cfg) + // ClearOldData before starting new bridge to do a proper setup. // // IMPORTANT: If you the change position of this you will need to wait @@ -234,7 +159,7 @@ func run(context *cli.Context) (contextError error) { // nolint[funlen] if err != nil { log.Warn("Bridge is already running") if err := api.CheckOtherInstanceAndFocus(pref.GetInt(preferences.APIPortKey), tls); err != nil { - numberOfCrashes = maxAllowedCrashes + cmd.DisableRestart() log.Error("Second instance: ", err) } return cli.NewExitError("Bridge is already running.", 3) @@ -254,7 +179,7 @@ func run(context *cli.Context) (contextError error) { // nolint[funlen] } if doMemoryProfile := context.GlobalBool("mem-prof"); doMemoryProfile { - defer makeMemoryProfile() + defer cmd.MakeMemoryProfile() } // Now we initialize all Bridge parts. @@ -262,7 +187,7 @@ func run(context *cli.Context) (contextError error) { // nolint[funlen] eventListener := listener.New() events.SetupEvents(eventListener) - credentialsStore, credentialsError := credentials.NewStore("bridge") + credentialsStore, credentialsError := credentials.NewStore(appName) if credentialsError != nil { log.Error("Could not get credentials store: ", credentialsError) } @@ -338,7 +263,7 @@ func run(context *cli.Context) (contextError error) { // nolint[funlen] } if frontend.IsAppRestarting() { - restartApp() + cmd.RestartApp() } return nil @@ -348,7 +273,7 @@ func run(context *cli.Context) (contextError error) { // nolint[funlen] // It will happen only when c10/prefs.json exists and c11/prefs.json not. // No configuration changed between c10 and c11 versions. func migratePreferencesFromC10(cfg *config.Config) { - pref10Path := config.New(constants.AppShortName, constants.Version, constants.Revision, "c10").GetPreferencesPath() + pref10Path := config.New(appName, constants.Version, constants.Revision, "c10").GetPreferencesPath() if _, err := os.Stat(pref10Path); os.IsNotExist(err) { log.WithField("path", pref10Path).Trace("Old preferences does not exist, migration skipped") return @@ -374,68 +299,3 @@ func migratePreferencesFromC10(cfg *config.Config) { log.Info("Preferences migrated") } - -// generateVersionFiles writes a JSON file with details about current build. -// Those files are used for upgrading the app. -func generateVersionFiles(updates *updates.Updates, dir string) { - log.Info("Generating version files") - for _, goos := range []string{"windows", "darwin", "linux"} { - log.Debug("Generating JSON for ", goos) - if err := updates.CreateJSONAndSign(dir, goos); err != nil { - log.Error(err) - } - } -} - -func makeMemoryProfile() { - name := "./mem.pprof" - f, err := os.Create(name) - if err != nil { - log.Error("Could not create memory profile: ", err) - } - if abs, err := filepath.Abs(name); err == nil { - name = abs - } - log.Info("Writing memory profile to ", name) - runtime.GC() // get up-to-date statistics - if err := pprof.WriteHeapProfile(f); err != nil { - log.Error("Could not write memory profile: ", err) - } - _ = f.Close() -} - -// filterRestartNumberFromArgs removes flag with a number how many restart we already did. -// See restartApp how that number is used. -func filterRestartNumberFromArgs() { - tmp := os.Args[:0] - for i, arg := range os.Args { - if !strings.HasPrefix(arg, "--restart_") { - tmp = append(tmp, arg) - continue - } - var err error - numberOfCrashes, err = strconv.Atoi(os.Args[i][10:]) - if err != nil { - numberOfCrashes = maxAllowedCrashes - } - } - os.Args = tmp -} - -// restartApp starts a new instance in background. -func restartApp() { - if numberOfCrashes >= maxAllowedCrashes { - log.Error("Too many crashes") - return - } - if exeFile, err := os.Executable(); err == nil { - arguments := append(os.Args[1:], fmt.Sprintf("--restart_%d", numberOfCrashes)) - cmd := exec.Command(exeFile, arguments...) //nolint[gosec] - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Stdin = os.Stdin - if err := cmd.Start(); err != nil { - log.Error("Restart failed: ", err) - } - } -} diff --git a/cmd/Import-Export/main.go b/cmd/Import-Export/main.go index f605afc7..ac653ca3 100644 --- a/cmd/Import-Export/main.go +++ b/cmd/Import-Export/main.go @@ -18,108 +18,40 @@ package main import ( - "fmt" "os" - "os/exec" - "path/filepath" - "runtime" "runtime/pprof" - "strconv" - "strings" + "github.com/ProtonMail/proton-bridge/internal/cmd" "github.com/ProtonMail/proton-bridge/internal/events" "github.com/ProtonMail/proton-bridge/internal/frontend" "github.com/ProtonMail/proton-bridge/internal/importexport" "github.com/ProtonMail/proton-bridge/internal/users/credentials" - "github.com/ProtonMail/proton-bridge/pkg/args" "github.com/ProtonMail/proton-bridge/pkg/config" "github.com/ProtonMail/proton-bridge/pkg/constants" "github.com/ProtonMail/proton-bridge/pkg/listener" "github.com/ProtonMail/proton-bridge/pkg/pmapi" "github.com/ProtonMail/proton-bridge/pkg/updates" - "github.com/getsentry/raven-go" + "github.com/allan-simon/go-singleinstance" "github.com/sirupsen/logrus" "github.com/urfave/cli" ) +const ( + appName = "importExport" + appNameDash = "import-export" +) + var ( log = logrus.WithField("pkg", "main") //nolint[gochecknoglobals] - - // How many crashes in a row. - numberOfCrashes = 0 //nolint[gochecknoglobals] - - // After how many crashes import/export gives up starting. - maxAllowedCrashes = 10 //nolint[gochecknoglobals] ) func main() { - constants.AppShortName = "importExport" //TODO - - if err := raven.SetDSN(constants.DSNSentry); err != nil { - log.WithError(err).Errorln("Can not setup sentry DSN") - } - raven.SetRelease(constants.Revision) - - args.FilterProcessSerialNumberFromArgs() - filterRestartNumberFromArgs() - - app := cli.NewApp() - app.Name = "Protonmail Import/Export" - app.Version = constants.BuildVersion - app.Flags = []cli.Flag{ - cli.StringFlag{ - Name: "log-level, l", - Usage: "Set the log level (one of panic, fatal, error, warn, info, debug, debug-client, debug-server)"}, - cli.BoolFlag{ - Name: "cli, c", - Usage: "Use command line interface"}, - cli.StringFlag{ - Name: "version-json, g", - Usage: "Generate json version file"}, - cli.BoolFlag{ - Name: "mem-prof, m", - Usage: "Generate memory profile"}, - cli.BoolFlag{ - Name: "cpu-prof, p", - Usage: "Generate CPU profile"}, - } - app.Usage = "ProtonMail Import/Export" - app.Action = run - - // Always log the basic info about current import/export. - logrus.SetLevel(logrus.InfoLevel) - log.WithField("version", constants.Version). - WithField("revision", constants.Revision). - WithField("runtime", runtime.GOOS). - WithField("build", constants.BuildTime). - WithField("args", os.Args). - WithField("appLong", app.Name). - WithField("appShort", constants.AppShortName). - Info("Run app") - if err := app.Run(os.Args); err != nil { - log.Error("Program exited with error: ", err) - } -} - -type panicHandler struct { - cfg *config.Config - err *error // Pointer to error of cli action. -} - -func (ph *panicHandler) HandlePanic() { - r := recover() - if r == nil { - return - } - - config.HandlePanic(ph.cfg, fmt.Sprintf("Recover: %v", r)) - frontend.HandlePanic("ProtonMail Import-Export") - - *ph.err = cli.NewExitError("Panic and restart", 255) - numberOfCrashes++ - log.Error("Restarting after panic") - restartApp() - os.Exit(255) + cmd.Main( + "ProtonMail Import/Export", + "ProtonMail Import/Export tool", + nil, + run, + ) } // run initializes and starts everything in a precise order. @@ -127,13 +59,17 @@ func (ph *panicHandler) HandlePanic() { // IMPORTANT: ***Read the comments before CHANGING the order *** func run(context *cli.Context) (contextError error) { // nolint[funlen] // We need to have config instance to setup a logs, panic handler, etc ... - cfg := config.New(constants.AppShortName, constants.Version, constants.Revision, "") + cfg := config.New(appName, constants.Version, constants.Revision, "") // We want to know about any problem. Our PanicHandler calls sentry which is // not dependent on anything else. If that fails, it tries to create crash // report which will not be possible if no folder can be created. That's the // only problem we will not be notified about in any way. - panicHandler := &panicHandler{cfg, &contextError} + panicHandler := &cmd.PanicHandler{ + AppName: "ProtonMail Import/Export", + Config: cfg, + Err: &contextError, + } defer panicHandler.HandlePanic() // First we need config and create necessary folder; it's dependency for everything. @@ -154,21 +90,22 @@ func run(context *cli.Context) (contextError error) { // nolint[funlen] // It's safe to get version JSON file even when other instance is running. // (thus we put it before check of presence of other Import/Export instance). - updates := updates.New( - constants.AppShortName, - constants.Version, - constants.Revision, - constants.BuildTime, - importexport.ReleaseNotes, - importexport.ReleaseFixedBugs, - cfg.GetUpdateDir(), - ) + updates := updates.NewImportExport(cfg.GetUpdateDir()) if dir := context.GlobalString("version-json"); dir != "" { - generateVersionFiles(updates, dir) + cmd.GenerateVersionFiles(updates, dir) return nil } + // Now we can try to proceed with starting the import/export. First we need to ensure + // this is the only instance. If not, we will end and focus the existing one. + lock, err := singleinstance.CreateLockFile(cfg.GetLockPath()) + if err != nil { + log.Warn("Import/Export is already running") + return cli.NewExitError("Import/Export is already running.", 3) + } + defer lock.Close() //nolint[errcheck] + // In case user wants to do CPU or memory profiles... if doCPUProfile := context.GlobalBool("cpu-prof"); doCPUProfile { f, err := os.Create("cpu.pprof") @@ -182,7 +119,7 @@ func run(context *cli.Context) (contextError error) { // nolint[funlen] } if doMemoryProfile := context.GlobalBool("mem-prof"); doMemoryProfile { - defer makeMemoryProfile() + defer cmd.MakeMemoryProfile() } // Now we initialize all Import/Export parts. @@ -190,7 +127,7 @@ func run(context *cli.Context) (contextError error) { // nolint[funlen] eventListener := listener.New() events.SetupEvents(eventListener) - credentialsStore, credentialsError := credentials.NewStore("import-export") + credentialsStore, credentialsError := credentials.NewStore(appNameDash) if credentialsError != nil { log.Error("Could not get credentials store: ", credentialsError) } @@ -224,73 +161,8 @@ func run(context *cli.Context) (contextError error) { // nolint[funlen] } if frontend.IsAppRestarting() { - restartApp() + cmd.RestartApp() } return nil } - -// generateVersionFiles writes a JSON file with details about current build. -// Those files are used for upgrading the app. -func generateVersionFiles(updates *updates.Updates, dir string) { - log.Info("Generating version files") - for _, goos := range []string{"windows", "darwin", "linux"} { - log.Debug("Generating JSON for ", goos) - if err := updates.CreateJSONAndSign(dir, goos); err != nil { - log.Error(err) - } - } -} - -func makeMemoryProfile() { - name := "./mem.pprof" - f, err := os.Create(name) - if err != nil { - log.Error("Could not create memory profile: ", err) - } - if abs, err := filepath.Abs(name); err == nil { - name = abs - } - log.Info("Writing memory profile to ", name) - runtime.GC() // get up-to-date statistics - if err := pprof.WriteHeapProfile(f); err != nil { - log.Error("Could not write memory profile: ", err) - } - _ = f.Close() -} - -// filterRestartNumberFromArgs removes flag with a number how many restart we already did. -// See restartApp how that number is used. -func filterRestartNumberFromArgs() { - tmp := os.Args[:0] - for i, arg := range os.Args { - if !strings.HasPrefix(arg, "--restart_") { - tmp = append(tmp, arg) - continue - } - var err error - numberOfCrashes, err = strconv.Atoi(os.Args[i][10:]) - if err != nil { - numberOfCrashes = maxAllowedCrashes - } - } - os.Args = tmp -} - -// restartApp starts a new instance in background. -func restartApp() { - if numberOfCrashes >= maxAllowedCrashes { - log.Error("Too many crashes") - return - } - if exeFile, err := os.Executable(); err == nil { - arguments := append(os.Args[1:], fmt.Sprintf("--restart_%d", numberOfCrashes)) - cmd := exec.Command(exeFile, arguments...) //nolint[gosec] - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Stdin = os.Stdin - if err := cmd.Start(); err != nil { - log.Error("Restart failed: ", err) - } - } -} diff --git a/go.mod b/go.mod index cb4c80ba..c499d754 100644 --- a/go.mod +++ b/go.mod @@ -35,6 +35,7 @@ require ( github.com/emersion/go-imap-specialuse v0.0.0-20200722111535-598ff00e4075 github.com/emersion/go-imap-unselect v0.0.0-20171113212723-b985794e5f26 github.com/emersion/go-mbox v1.0.0 + github.com/emersion/go-message v0.11.1 github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe github.com/emersion/go-vcard v0.0.0-20190105225839-8856043f13c5 // indirect diff --git a/pkg/args/args.go b/internal/cmd/args.go similarity index 90% rename from pkg/args/args.go rename to internal/cmd/args.go index 12edbb20..79532e0d 100644 --- a/pkg/args/args.go +++ b/internal/cmd/args.go @@ -15,16 +15,16 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -package args +package cmd import ( "os" "strings" ) -// FilterProcessSerialNumberFromArgs removes additional flag from MacOS. More info ProcessSerialNumber +// filterProcessSerialNumberFromArgs removes additional flag from MacOS. More info ProcessSerialNumber // http://mirror.informatimago.com/next/developer.apple.com/documentation/Carbon/Reference/Process_Manager/prmref_main/data_type_5.html#//apple_ref/doc/uid/TP30000208/C001951 -func FilterProcessSerialNumberFromArgs() { +func filterProcessSerialNumberFromArgs() { tmp := os.Args[:0] for _, arg := range os.Args { if !strings.Contains(arg, "-psn_") { diff --git a/internal/cmd/main.go b/internal/cmd/main.go new file mode 100644 index 00000000..e83d806b --- /dev/null +++ b/internal/cmd/main.go @@ -0,0 +1,86 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package cmd + +import ( + "os" + "runtime" + + "github.com/ProtonMail/proton-bridge/pkg/constants" + "github.com/getsentry/raven-go" + "github.com/sirupsen/logrus" + "github.com/urfave/cli" +) + +var ( + log = logrus.WithField("pkg", "cmd") //nolint[gochecknoglobals] + + baseFlags = []cli.Flag{ //nolint[gochecknoglobals] + cli.StringFlag{ + Name: "log-level, l", + Usage: "Set the log level (one of panic, fatal, error, warn, info, debug, debug-client, debug-server)"}, + cli.BoolFlag{ + Name: "cli, c", + Usage: "Use command line interface"}, + cli.StringFlag{ + Name: "version-json, g", + Usage: "Generate json version file"}, + cli.BoolFlag{ + Name: "mem-prof, m", + Usage: "Generate memory profile"}, + cli.BoolFlag{ + Name: "cpu-prof, p", + Usage: "Generate CPU profile"}, + } +) + +// Main sets up Sentry, filters out unwanted args, creates app and runs it. +func Main(appName, usage string, extraFlags []cli.Flag, run func(*cli.Context) error) { + if err := raven.SetDSN(constants.DSNSentry); err != nil { + log.WithError(err).Errorln("Can not setup sentry DSN") + } + raven.SetRelease(constants.Revision) + + filterProcessSerialNumberFromArgs() + filterRestartNumberFromArgs() + + app := newApp(appName, usage, extraFlags, run) + + logrus.SetLevel(logrus.InfoLevel) + log.WithField("version", constants.Version). + WithField("revision", constants.Revision). + WithField("build", constants.BuildTime). + WithField("runtime", runtime.GOOS). + WithField("args", os.Args). + WithField("appName", app.Name). + Info("Run app") + + if err := app.Run(os.Args); err != nil { + log.Error("Program exited with error: ", err) + } +} + +func newApp(appName, usage string, extraFlags []cli.Flag, run func(*cli.Context) error) *cli.App { + app := cli.NewApp() + app.Name = appName + app.Usage = usage + app.Version = constants.BuildVersion + app.Flags = append(baseFlags, extraFlags...) //nolint[gocritic] + app.Action = run + return app +} diff --git a/internal/cmd/memory_profile.go b/internal/cmd/memory_profile.go new file mode 100644 index 00000000..5b09bf54 --- /dev/null +++ b/internal/cmd/memory_profile.go @@ -0,0 +1,43 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package cmd + +import ( + "os" + "path/filepath" + "runtime" + "runtime/pprof" +) + +// MakeMemoryProfile generates memory pprof. +func MakeMemoryProfile() { + name := "./mem.pprof" + f, err := os.Create(name) + if err != nil { + log.Error("Could not create memory profile: ", err) + } + if abs, err := filepath.Abs(name); err == nil { + name = abs + } + log.Info("Writing memory profile to ", name) + runtime.GC() // get up-to-date statistics + if err := pprof.WriteHeapProfile(f); err != nil { + log.Error("Could not write memory profile: ", err) + } + _ = f.Close() +} diff --git a/internal/cmd/restart.go b/internal/cmd/restart.go new file mode 100644 index 00000000..abd3036e --- /dev/null +++ b/internal/cmd/restart.go @@ -0,0 +1,108 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package cmd + +import ( + "fmt" + "os" + "os/exec" + "strconv" + "strings" + + "github.com/ProtonMail/proton-bridge/internal/frontend" + "github.com/ProtonMail/proton-bridge/pkg/config" + "github.com/urfave/cli" +) + +const ( + // After how many crashes app gives up starting. + maxAllowedCrashes = 10 +) + +var ( + // How many crashes happened so far in a row. + // It will be filled from args by `filterRestartNumberFromArgs`. + // Every call of `HandlePanic` will increase this number. + // Then it will be passed as argument to the next try by `RestartApp`. + numberOfCrashes = 0 //nolint[gochecknoglobals] +) + +// filterRestartNumberFromArgs removes flag with a number how many restart we already did. +// See restartApp how that number is used. +func filterRestartNumberFromArgs() { + tmp := os.Args[:0] + for i, arg := range os.Args { + if !strings.HasPrefix(arg, "--restart_") { + tmp = append(tmp, arg) + continue + } + var err error + numberOfCrashes, err = strconv.Atoi(os.Args[i][10:]) + if err != nil { + numberOfCrashes = maxAllowedCrashes + } + } + os.Args = tmp +} + +// DisableRestart disables restart once `RestartApp` is called. +func DisableRestart() { + numberOfCrashes = maxAllowedCrashes +} + +// RestartApp starts a new instance in background. +func RestartApp() { + if numberOfCrashes >= maxAllowedCrashes { + log.Error("Too many crashes") + return + } + if exeFile, err := os.Executable(); err == nil { + arguments := append(os.Args[1:], fmt.Sprintf("--restart_%d", numberOfCrashes)) + cmd := exec.Command(exeFile, arguments...) //nolint[gosec] + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + if err := cmd.Start(); err != nil { + log.Error("Restart failed: ", err) + } + } +} + +// PanicHandler defines HandlePanic which can be used anywhere in defer. +type PanicHandler struct { + AppName string + Config *config.Config + Err *error // Pointer to error of cli action. +} + +// HandlePanic should be called in defer to ensure restart of app after error. +func (ph *PanicHandler) HandlePanic() { + r := recover() + if r == nil { + return + } + + config.HandlePanic(ph.Config, fmt.Sprintf("Recover: %v", r)) + frontend.HandlePanic(ph.AppName) + + *ph.Err = cli.NewExitError("Panic and restart", 255) + numberOfCrashes++ + log.Error("Restarting after panic") + RestartApp() + os.Exit(255) +} diff --git a/internal/cmd/version_file.go b/internal/cmd/version_file.go new file mode 100644 index 00000000..fe0e8e9c --- /dev/null +++ b/internal/cmd/version_file.go @@ -0,0 +1,32 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package cmd + +import "github.com/ProtonMail/proton-bridge/pkg/updates" + +// GenerateVersionFiles writes a JSON file with details about current build. +// Those files are used for upgrading the app. +func GenerateVersionFiles(updates *updates.Updates, dir string) { + log.Info("Generating version files") + for _, goos := range []string{"windows", "darwin", "linux"} { + log.Debug("Generating JSON for ", goos) + if err := updates.CreateJSONAndSign(dir, goos); err != nil { + log.Error(err) + } + } +} diff --git a/internal/frontend/cli-ie/frontend.go b/internal/frontend/cli-ie/frontend.go index 7bb11586..cf769fe6 100644 --- a/internal/frontend/cli-ie/frontend.go +++ b/internal/frontend/cli-ie/frontend.go @@ -15,7 +15,7 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -// Package cli provides CLI interface of the Bridge. +// Package cli provides CLI interface of the Import/Export. package cli import ( @@ -227,7 +227,11 @@ func (f *frontendCLI) Loop(credentialsError error) error { return credentialsError } - f.Print(`Welcome to ProtonMail Import/Export interactive shell`) + f.Print(` +Welcome to ProtonMail Import/Export interactive shell + +WARNING: CLI is experimental feature and does not cover all functionality yet. + `) f.Run() return nil } diff --git a/internal/frontend/cli-ie/updates.go b/internal/frontend/cli-ie/updates.go index b30da468..62332dc8 100644 --- a/internal/frontend/cli-ie/updates.go +++ b/internal/frontend/cli-ie/updates.go @@ -20,7 +20,7 @@ package cli import ( "strings" - "github.com/ProtonMail/proton-bridge/internal/bridge" + "github.com/ProtonMail/proton-bridge/internal/importexport" "github.com/ProtonMail/proton-bridge/pkg/updates" "github.com/abiosoft/ishell" ) @@ -47,7 +47,7 @@ func (f *frontendCLI) printLocalReleaseNotes(c *ishell.Context) { } func (f *frontendCLI) printReleaseNotes(versionInfo updates.VersionInfo) { - f.Println(bold("ProtonMail Bridge "+versionInfo.Version), "\n") + f.Println(bold("ProtonMail Import/Export "+versionInfo.Version), "\n") if versionInfo.ReleaseNotes != "" { f.Println(bold("Release Notes")) f.Println(versionInfo.ReleaseNotes) @@ -59,7 +59,7 @@ func (f *frontendCLI) printReleaseNotes(versionInfo updates.VersionInfo) { } func (f *frontendCLI) printCredits(c *ishell.Context) { - for _, pkg := range strings.Split(bridge.Credits, ";") { + for _, pkg := range strings.Split(importexport.Credits, ";") { f.Println(pkg) } } diff --git a/internal/frontend/cli-ie/utils.go b/internal/frontend/cli-ie/utils.go index f5a97000..6f01d2fe 100644 --- a/internal/frontend/cli-ie/utils.go +++ b/internal/frontend/cli-ie/utils.go @@ -98,7 +98,7 @@ func (f *frontendCLI) notifyNeedUpgrade() { func (f *frontendCLI) notifyCredentialsError() { // Print in 80-column width. - f.Println("ProtonMail Bridge is not able to detect a supported password manager") + f.Println("ProtonMail Import/Export is not able to detect a supported password manager") f.Println("(pass, gnome-keyring). Please install and set up a supported password manager") f.Println("and restart the application.") } @@ -109,7 +109,7 @@ func (f *frontendCLI) notifyCertIssue() { be insecure. Description: -ProtonMail Bridge was not able to establish a secure connection to Proton +ProtonMail Import/Export was not able to establish a secure connection to Proton servers due to a TLS certificate error. This means your connection may potentially be insecure and susceptible to monitoring by third parties. diff --git a/internal/frontend/cli/updates.go b/internal/frontend/cli/updates.go index 55d35e16..b30da468 100644 --- a/internal/frontend/cli/updates.go +++ b/internal/frontend/cli/updates.go @@ -47,7 +47,7 @@ func (f *frontendCLI) printLocalReleaseNotes(c *ishell.Context) { } func (f *frontendCLI) printReleaseNotes(versionInfo updates.VersionInfo) { - f.Println(bold("ProtonMail Import/Export "+versionInfo.Version), "\n") + f.Println(bold("ProtonMail Bridge "+versionInfo.Version), "\n") if versionInfo.ReleaseNotes != "" { f.Println(bold("Release Notes")) f.Println(versionInfo.ReleaseNotes) diff --git a/internal/frontend/qt-common/path_status.go b/internal/frontend/qt-common/path_status.go index e6845f92..bf76d6d8 100644 --- a/internal/frontend/qt-common/path_status.go +++ b/internal/frontend/qt-common/path_status.go @@ -67,12 +67,12 @@ func CheckPathStatus(path string) int { tmpFile += "tmp" _, err = os.Lstat(tmpFile) } - err = os.Mkdir(tmpFile, 0777) + err = os.Mkdir(tmpFile, 0750) if err != nil { stat |= PathWrongPermissions return int(stat) } - os.Remove(tmpFile) + _ = os.Remove(tmpFile) } else { stat |= PathNotADir } diff --git a/internal/importexport/credits.go b/internal/importexport/credits.go index 87eed1d7..311da61d 100644 --- a/internal/importexport/credits.go +++ b/internal/importexport/credits.go @@ -15,8 +15,8 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -// Code generated by ./credits.sh at Thu 04 Jun 2020 04:19:16 PM CEST. DO NOT EDIT. +// Code generated by ./credits.sh at Mon Jul 13 14:02:21 CEST 2020. DO NOT EDIT. package importexport -const Credits = "github.com/0xAX/notificator;github.com/abiosoft/ishell;github.com/abiosoft/readline;github.com/allan-simon/go-singleinstance;github.com/andybalholm/cascadia;github.com/certifi/gocertifi;github.com/chzyer/logex;github.com/chzyer/test;github.com/cucumber/godog;github.com/docker/docker-credential-helpers;github.com/emersion/go-imap;github.com/emersion/go-imap-appendlimit;github.com/emersion/go-imap-idle;github.com/emersion/go-imap-move;github.com/emersion/go-imap-quota;github.com/emersion/go-imap-specialuse;github.com/emersion/go-imap-unselect;github.com/emersion/go-mbox;github.com/emersion/go-sasl;github.com/emersion/go-smtp;github.com/emersion/go-textwrapper;github.com/emersion/go-vcard;github.com/fatih/color;github.com/flynn-archive/go-shlex;github.com/getsentry/raven-go;github.com/golang/mock;github.com/google/go-cmp;github.com/gopherjs/gopherjs;github.com/go-resty/resty/v2;github.com/hashicorp/go-multierror;github.com/jameskeane/bcrypt;github.com/jaytaylor/html2text;github.com/jhillyerd/enmime;github.com/kardianos/osext;github.com/keybase/go-keychain;github.com/logrusorgru/aurora;github.com/miekg/dns;github.com/myesui/uuid;github.com/nsf/jsondiff;github.com/pkg/errors;github.com/ProtonMail/bcrypt;github.com/ProtonMail/crypto;github.com/ProtonMail/docker-credential-helpers;github.com/ProtonMail/go-appdir;github.com/ProtonMail/go-apple-mobileconfig;github.com/ProtonMail/go-autostart;github.com/ProtonMail/go-imap-id;github.com/ProtonMail/gopenpgp;github.com/ProtonMail/go-smtp;github.com/ProtonMail/go-vcard;github.com/sirupsen/logrus;github.com/skratchdot/open-golang;github.com/stretchr/testify;github.com/therecipe/qt;github.com/twinj/uuid;github.com/urfave/cli;go.etcd.io/bbolt;golang.org/x/crypto;golang.org/x/net;golang.org/x/text;gopkg.in/stretchr/testify.v1;;Font Awesome 4.7.0;;Qt 5.13 by Qt group;" +const Credits = "github.com/0xAX/notificator;github.com/ProtonMail/bcrypt;github.com/ProtonMail/crypto;github.com/ProtonMail/docker-credential-helpers;github.com/ProtonMail/go-appdir;github.com/ProtonMail/go-apple-mobileconfig;github.com/ProtonMail/go-autostart;github.com/ProtonMail/go-imap-id;github.com/ProtonMail/go-smtp;github.com/ProtonMail/go-vcard;github.com/ProtonMail/gopenpgp/v2;github.com/abiosoft/ishell;github.com/abiosoft/readline;github.com/allan-simon/go-singleinstance;github.com/andybalholm/cascadia;github.com/certifi/gocertifi;github.com/chzyer/logex;github.com/chzyer/test;github.com/cucumber/godog;github.com/docker/docker-credential-helpers;github.com/emersion/go-imap;github.com/emersion/go-imap-appendlimit;github.com/emersion/go-imap-idle;github.com/emersion/go-imap-move;github.com/emersion/go-imap-quota;github.com/emersion/go-imap-specialuse;github.com/emersion/go-imap-unselect;github.com/emersion/go-mbox;github.com/emersion/go-message;github.com/emersion/go-sasl;github.com/emersion/go-smtp;github.com/emersion/go-textwrapper;github.com/emersion/go-vcard;github.com/fatih/color;github.com/flynn-archive/go-shlex;github.com/getsentry/raven-go;github.com/go-resty/resty/v2;github.com/golang/mock;github.com/google/go-cmp;github.com/gopherjs/gopherjs;github.com/hashicorp/go-multierror;github.com/jameskeane/bcrypt;github.com/jaytaylor/html2text;github.com/jhillyerd/enmime;github.com/kardianos/osext;github.com/keybase/go-keychain;github.com/logrusorgru/aurora;github.com/miekg/dns;github.com/myesui/uuid;github.com/nsf/jsondiff;github.com/pkg/errors;github.com/sirupsen/logrus;github.com/skratchdot/open-golang;github.com/stretchr/testify;github.com/therecipe/qt;github.com/twinj/uuid;github.com/urfave/cli;go.etcd.io/bbolt;golang.org/x/crypto;golang.org/x/net;golang.org/x/text;gopkg.in/stretchr/testify.v1;;Font Awesome 4.7.0;;Qt 5.13 by Qt group;" diff --git a/internal/importexport/release_notes.go b/internal/importexport/release_notes.go index 456f472c..71713e72 100644 --- a/internal/importexport/release_notes.go +++ b/internal/importexport/release_notes.go @@ -15,7 +15,7 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -// Code generated by ./release-notes.sh at 'Thu Jun 4 15:54:31 CEST 2020'. DO NOT EDIT. +// Code generated by ./release-notes.sh at 'Thu Jun 25 10:06:16 CEST 2020'. DO NOT EDIT. package importexport diff --git a/internal/transfer/mailbox.go b/internal/transfer/mailbox.go index b4fbaf46..430db7c9 100644 --- a/internal/transfer/mailbox.go +++ b/internal/transfer/mailbox.go @@ -41,13 +41,9 @@ func (m Mailbox) Hash() string { // LeastUsedColor is intended to return color for creating a new inbox or label func LeastUsedColor(mailboxes []Mailbox) string { usedColors := []string{} - - if mailboxes != nil { - for _, m := range mailboxes { - usedColors = append(usedColors, m.Color) - } + for _, m := range mailboxes { + usedColors = append(usedColors, m.Color) } - return pmapi.LeastUsedColor(usedColors) } diff --git a/internal/transfer/message.go b/internal/transfer/message.go index 5c939305..fec7be38 100644 --- a/internal/transfer/message.go +++ b/internal/transfer/message.go @@ -56,6 +56,10 @@ type MessageStatus struct { Time time.Time } +func (status *MessageStatus) String() string { + return fmt.Sprintf("%s (%s, %s, %s): %s", status.SourceID, status.Subject, status.From, status.Time, status.GetErrorMessage()) +} + func (status *MessageStatus) setDetailsFromHeader(header mail.Header) { dec := &mime.WordDecoder{} diff --git a/internal/transfer/progress.go b/internal/transfer/progress.go index ee463f45..28c37f38 100644 --- a/internal/transfer/progress.go +++ b/internal/transfer/progress.go @@ -139,8 +139,10 @@ func (p *Progress) messageExported(messageID string, body []byte, err error) { p.log.WithField("id", messageID).WithError(err).Debug("Message exported") status := p.messageStatuses[messageID] - status.exported = true status.exportErr = err + if err == nil { + status.exported = true + } if len(body) > 0 { status.bodyHash = fmt.Sprintf("%x", sha256.Sum256(body)) @@ -166,8 +168,10 @@ func (p *Progress) messageImported(messageID, importID string, err error) { p.log.WithField("id", messageID).WithError(err).Debug("Message imported") p.messageStatuses[messageID].targetID = importID - p.messageStatuses[messageID].imported = true p.messageStatuses[messageID].importErr = err + if err == nil { + p.messageStatuses[messageID].imported = true + } // Import is the last step, now we can log the result to the report file. p.logMessage(messageID) diff --git a/internal/transfer/progress_test.go b/internal/transfer/progress_test.go index 68baeb16..3ec170d2 100644 --- a/internal/transfer/progress_test.go +++ b/internal/transfer/progress_test.go @@ -73,8 +73,8 @@ func TestProgressAddingMessages(t *testing.T) { failed, imported, exported, added, _ := progress.GetCounts() a.Equal(t, uint(4), added) - a.Equal(t, uint(4), exported) - a.Equal(t, uint(3), imported) + a.Equal(t, uint(2), exported) + a.Equal(t, uint(2), imported) a.Equal(t, uint(3), failed) errorsMap := map[string]string{} diff --git a/internal/transfer/provider_imap_source.go b/internal/transfer/provider_imap_source.go index 59dbf9ac..82d13f1c 100644 --- a/internal/transfer/provider_imap_source.go +++ b/internal/transfer/provider_imap_source.go @@ -68,7 +68,7 @@ func (p *IMAPProvider) loadMessageInfoMap(rules transferRules, progress *Progres continue } - messagesInfo := p.loadMessagesInfo(rule, progress, mailbox.UidValidity) + messagesInfo := p.loadMessagesInfo(rule, progress, mailbox.UidValidity, mailbox.Messages) res[rule.SourceMailbox.Name] = messagesInfo progress.updateCount(rule.SourceMailbox.Name, uint(len(messagesInfo))) } @@ -76,7 +76,7 @@ func (p *IMAPProvider) loadMessageInfoMap(rules transferRules, progress *Progres return res } -func (p *IMAPProvider) loadMessagesInfo(rule *Rule, progress *Progress, uidValidity uint32) map[string]imapMessageInfo { +func (p *IMAPProvider) loadMessagesInfo(rule *Rule, progress *Progress, uidValidity, count uint32) map[string]imapMessageInfo { messagesInfo := map[string]imapMessageInfo{} pageStart := uint32(1) @@ -86,6 +86,11 @@ func (p *IMAPProvider) loadMessagesInfo(rule *Rule, progress *Progress, uidValid break } + // Some servers do not accept message sequence number higher than the total count. + if pageEnd > count { + pageEnd = count + } + seqSet := &imap.SeqSet{} seqSet.AddRange(pageStart, pageEnd) @@ -114,7 +119,7 @@ func (p *IMAPProvider) loadMessagesInfo(rule *Rule, progress *Progress, uidValid } progress.callWrap(func() error { - return p.fetch(seqSet, items, processMessageCallback) + return p.fetch(rule.SourceMailbox.Name, seqSet, items, processMessageCallback) }) if pageMsgCount < imapPageSize { @@ -145,7 +150,6 @@ func (p *IMAPProvider) transferTo(rule *Rule, messagesInfo map[string]imapMessag if seqSetSize != 0 && (seqSetSize+messageInfo.size) > imapMaxFetchSize { log.WithField("mailbox", rule.SourceMailbox.Name).WithField("seq", seqSet).WithField("size", seqSetSize).Debug("Fetching messages") - p.exportMessages(rule, progress, ch, seqSet, uidToID) seqSet = &imap.SeqSet{} @@ -157,6 +161,11 @@ func (p *IMAPProvider) transferTo(rule *Rule, messagesInfo map[string]imapMessag seqSetSize += messageInfo.size uidToID[messageInfo.uid] = messageInfo.id } + + if len(uidToID) != 0 { + log.WithField("mailbox", rule.SourceMailbox.Name).WithField("seq", seqSet).WithField("size", seqSetSize).Debug("Fetching messages") + p.exportMessages(rule, progress, ch, seqSet, uidToID) + } } func (p *IMAPProvider) exportMessages(rule *Rule, progress *Progress, ch chan<- Message, seqSet *imap.SeqSet, uidToID map[uint32]string) { @@ -188,7 +197,7 @@ func (p *IMAPProvider) exportMessages(rule *Rule, progress *Progress, ch chan<- } progress.callWrap(func() error { - return p.uidFetch(seqSet, items, processMessageCallback) + return p.uidFetch(rule.SourceMailbox.Name, seqSet, items, processMessageCallback) }) } diff --git a/internal/transfer/provider_imap_utils.go b/internal/transfer/provider_imap_utils.go index 364e9ecc..36b3cb2c 100644 --- a/internal/transfer/provider_imap_utils.go +++ b/internal/transfer/provider_imap_utils.go @@ -49,16 +49,11 @@ func (l *imapErrorLogger) Println(v ...interface{}) { l.log.Errorln(v...) } -type imapDebugLogger struct { //nolint[unused] - log *logrus.Entry -} - -func (l *imapDebugLogger) Write(data []byte) (int, error) { - l.log.Trace(string(data)) - return len(data), nil -} - func (p *IMAPProvider) ensureConnection(callback func() error) error { + return p.ensureConnectionAndSelection(callback, "") +} + +func (p *IMAPProvider) ensureConnectionAndSelection(callback func() error, ensureSelectedIn string) error { var callErr error for i := 1; i <= imapRetries; i++ { callErr = callback() @@ -66,8 +61,8 @@ func (p *IMAPProvider) ensureConnection(callback func() error) error { return nil } - log.WithField("attempt", i).WithError(callErr).Warning("Call failed, trying reconnect") - err := p.tryReconnect() + log.WithField("attempt", i).WithError(callErr).Warning("IMAP call failed, trying reconnect") + err := p.tryReconnect(ensureSelectedIn) if err != nil { return err } @@ -75,7 +70,7 @@ func (p *IMAPProvider) ensureConnection(callback func() error) error { return errors.Wrap(callErr, "too many retries") } -func (p *IMAPProvider) tryReconnect() error { +func (p *IMAPProvider) tryReconnect(ensureSelectedIn string) error { start := time.Now() var previousErr error for { @@ -84,6 +79,7 @@ func (p *IMAPProvider) tryReconnect() error { } err := pmapi.CheckConnection() + log.WithError(err).Debug("Connection check") if err != nil { time.Sleep(imapReconnectSleep) previousErr = err @@ -91,24 +87,47 @@ func (p *IMAPProvider) tryReconnect() error { } err = p.reauth() + log.WithError(err).Debug("Reauth") if err != nil { time.Sleep(imapReconnectSleep) previousErr = err continue } + if ensureSelectedIn != "" { + _, err = p.client.Select(ensureSelectedIn, true) + log.WithError(err).Debug("Reselect") + if err != nil { + previousErr = err + continue + } + } + break } return nil } func (p *IMAPProvider) reauth() error { - if _, err := p.client.Capability(); err != nil { - state := p.client.State() - log.WithField("addr", p.addr).WithField("state", state).WithError(err).Debug("Reconnecting") - p.client = nil + var state imap.ConnState + + // In some cases once go-imap fails, we cannot issue another command + // because it would dead-lock. Let's simply ignore it, we want to open + // new connection anyway. + ch := make(chan struct{}) + go func() { + defer close(ch) + if _, err := p.client.Capability(); err != nil { + state = p.client.State() + } + }() + select { + case <-ch: + case <-time.After(30 * time.Second): } + log.WithField("addr", p.addr).WithField("state", state).Debug("Reconnecting") + p.client = nil return p.auth() } @@ -121,15 +140,25 @@ func (p *IMAPProvider) auth() error { //nolint[funlen] return errors.Wrap(err, "failed to dial server") } - client, err := imapClient.DialTLS(p.addr, nil) + var client *imapClient.Client + var err error + host, _, _ := net.SplitHostPort(p.addr) + if host == "127.0.0.1" { + client, err = imapClient.Dial(p.addr) + } else { + client, err = imapClient.DialTLS(p.addr, nil) + } if err != nil { return errors.Wrap(err, "failed to connect to server") } + client.ErrorLog = &imapErrorLogger{logrus.WithField("pkg", "imap-client")} - // Logrus have Writer helper but it fails for big messages because of - // bufio.MaxScanTokenSize limit. - // This spams a lot, uncomment once needed during development. - //client.SetDebug(&imapDebugLogger{logrus.WithField("pkg", "imap-client")}) + // Logrus `WriterLevel` fails for big messages because of bufio.MaxScanTokenSize limit. + // Also, this spams a lot, uncomment once needed during development. + //client.SetDebug(imap.NewDebugWriter( + // logrus.WithField("pkg", "imap/client").WriterLevel(logrus.TraceLevel), + // logrus.WithField("pkg", "imap/server").WriterLevel(logrus.TraceLevel), + //)) p.client = client log.Info("Connected") @@ -205,16 +234,16 @@ func (p *IMAPProvider) selectIn(mailboxName string) (mailbox *imap.MailboxStatus return } -func (p *IMAPProvider) fetch(seqSet *imap.SeqSet, items []imap.FetchItem, processMessageCallback func(m *imap.Message)) error { - return p.fetchHelper(false, seqSet, items, processMessageCallback) +func (p *IMAPProvider) fetch(ensureSelectedIn string, seqSet *imap.SeqSet, items []imap.FetchItem, processMessageCallback func(m *imap.Message)) error { + return p.fetchHelper(false, ensureSelectedIn, seqSet, items, processMessageCallback) } -func (p *IMAPProvider) uidFetch(seqSet *imap.SeqSet, items []imap.FetchItem, processMessageCallback func(m *imap.Message)) error { - return p.fetchHelper(true, seqSet, items, processMessageCallback) +func (p *IMAPProvider) uidFetch(ensureSelectedIn string, seqSet *imap.SeqSet, items []imap.FetchItem, processMessageCallback func(m *imap.Message)) error { + return p.fetchHelper(true, ensureSelectedIn, seqSet, items, processMessageCallback) } -func (p *IMAPProvider) fetchHelper(uid bool, seqSet *imap.SeqSet, items []imap.FetchItem, processMessageCallback func(m *imap.Message)) error { - return p.ensureConnection(func() error { +func (p *IMAPProvider) fetchHelper(uid bool, ensureSelectedIn string, seqSet *imap.SeqSet, items []imap.FetchItem, processMessageCallback func(m *imap.Message)) error { + return p.ensureConnectionAndSelection(func() error { messagesCh := make(chan *imap.Message) doneCh := make(chan error) @@ -232,5 +261,5 @@ func (p *IMAPProvider) fetchHelper(uid bool, seqSet *imap.SeqSet, items []imap.F err := <-doneCh return err - }) + }, ensureSelectedIn) } diff --git a/internal/transfer/provider_mbox_target.go b/internal/transfer/provider_mbox_target.go index 610f8cf2..44f450f3 100644 --- a/internal/transfer/provider_mbox_target.go +++ b/internal/transfer/provider_mbox_target.go @@ -45,7 +45,7 @@ func (p *MBOXProvider) TransferFrom(rules transferRules, progress *Progress, ch defer log.Info("Finished transfer from channel to MBOX") for msg := range ch { - for progress.shouldStop() { + if progress.shouldStop() { break } diff --git a/internal/transfer/provider_pmapi.go b/internal/transfer/provider_pmapi.go index f6f14e27..61f708de 100644 --- a/internal/transfer/provider_pmapi.go +++ b/internal/transfer/provider_pmapi.go @@ -20,7 +20,7 @@ package transfer import ( "sort" - "github.com/ProtonMail/gopenpgp/crypto" + "github.com/ProtonMail/gopenpgp/v2/crypto" "github.com/ProtonMail/proton-bridge/pkg/pmapi" "github.com/pkg/errors" ) @@ -31,6 +31,9 @@ type PMAPIProvider struct { userID string addressID string keyRing *crypto.KeyRing + + importMsgReqMap map[string]*pmapi.ImportMsgReq // Key is msg transfer ID. + importMsgReqSize int } // NewPMAPIProvider returns new PMAPIProvider. @@ -45,6 +48,9 @@ func NewPMAPIProvider(clientManager ClientManager, userID, addressID string) (*P userID: userID, addressID: addressID, keyRing: keyRing, + + importMsgReqMap: map[string]*pmapi.ImportMsgReq{}, + importMsgReqSize: 0, }, nil } diff --git a/internal/transfer/provider_pmapi_source.go b/internal/transfer/provider_pmapi_source.go index 240a4a84..6c383812 100644 --- a/internal/transfer/provider_pmapi_source.go +++ b/internal/transfer/provider_pmapi_source.go @@ -19,6 +19,7 @@ package transfer import ( "fmt" + "sync" pkgMessage "github.com/ProtonMail/proton-bridge/pkg/message" "github.com/ProtonMail/proton-bridge/pkg/pmapi" @@ -32,11 +33,21 @@ func (p *PMAPIProvider) TransferTo(rules transferRules, progress *Progress, ch c log.Info("Started transfer from PMAPI to channel") defer log.Info("Finished transfer from PMAPI to channel") - go p.loadCounts(rules, progress) + // TransferTo cannot end sooner than loadCounts goroutine because + // loadCounts writes to channel in progress which would be closed. + // That can happen for really small accounts. + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + p.loadCounts(rules, progress) + }() for rule := range rules.iterateActiveRules() { p.transferTo(rule, progress, ch, rules.skipEncryptedMessages) } + + wg.Wait() } func (p *PMAPIProvider) loadCounts(rules transferRules, progress *Progress) { diff --git a/internal/transfer/provider_pmapi_target.go b/internal/transfer/provider_pmapi_target.go index 1034caa4..7e146825 100644 --- a/internal/transfer/provider_pmapi_target.go +++ b/internal/transfer/provider_pmapi_target.go @@ -28,6 +28,11 @@ import ( "github.com/pkg/errors" ) +const ( + pmapiImportBatchMaxItems = 10 + pmapiImportBatchMaxSize = 25 * 1000 * 1000 // 25 MB +) + // DefaultMailboxes returns the default mailboxes for default rules if no other is found. func (p *PMAPIProvider) DefaultMailboxes(_ Mailbox) []Mailbox { return []Mailbox{{ @@ -67,18 +72,19 @@ func (p *PMAPIProvider) TransferFrom(rules transferRules, progress *Progress, ch defer log.Info("Finished transfer from channel to PMAPI") for msg := range ch { - for progress.shouldStop() { + if progress.shouldStop() { break } - var importedID string - var err error if p.isMessageDraft(msg) { - importedID, err = p.importDraft(msg, rules.globalMailbox) + p.transferDraft(rules, progress, msg) } else { - importedID, err = p.importMessage(msg, rules.globalMailbox) + p.transferMessage(rules, progress, msg) } - progress.messageImported(msg.ID, importedID, err) + } + + if len(p.importMsgReqMap) > 0 { + p.importMessages(progress) } } @@ -91,6 +97,11 @@ func (p *PMAPIProvider) isMessageDraft(msg Message) bool { return false } +func (p *PMAPIProvider) transferDraft(rules transferRules, progress *Progress, msg Message) { + importedID, err := p.importDraft(msg, rules.globalMailbox) + progress.messageImported(msg.ID, importedID, err) +} + func (p *PMAPIProvider) importDraft(msg Message, globalMailbox *Mailbox) (string, error) { message, attachmentReaders, err := p.parseMessage(msg) if err != nil { @@ -138,15 +149,30 @@ func (p *PMAPIProvider) importDraft(msg Message, globalMailbox *Mailbox) (string return draft.ID, nil } -func (p *PMAPIProvider) importMessage(msg Message, globalMailbox *Mailbox) (string, error) { +func (p *PMAPIProvider) transferMessage(rules transferRules, progress *Progress, msg Message) { + importMsgReq, err := p.generateImportMsgReq(msg, rules.globalMailbox) + if err != nil { + progress.messageImported(msg.ID, "", err) + return + } + + importMsgReqSize := len(importMsgReq.Body) + if p.importMsgReqSize+importMsgReqSize > pmapiImportBatchMaxSize || len(p.importMsgReqMap) == pmapiImportBatchMaxItems { + p.importMessages(progress) + } + p.importMsgReqMap[msg.ID] = importMsgReq + p.importMsgReqSize += importMsgReqSize +} + +func (p *PMAPIProvider) generateImportMsgReq(msg Message, globalMailbox *Mailbox) (*pmapi.ImportMsgReq, error) { message, attachmentReaders, err := p.parseMessage(msg) if err != nil { - return "", errors.Wrap(err, "failed to parse message") + return nil, errors.Wrap(err, "failed to parse message") } body, err := p.encryptMessage(message, attachmentReaders) if err != nil { - return "", errors.Wrap(err, "failed to encrypt message") + return nil, errors.Wrap(err, "failed to encrypt message") } unread := 0 @@ -165,26 +191,14 @@ func (p *PMAPIProvider) importMessage(msg Message, globalMailbox *Mailbox) (stri labelIDs = append(labelIDs, globalMailbox.ID) } - importMsgReq := &pmapi.ImportMsgReq{ + return &pmapi.ImportMsgReq{ AddressID: p.addressID, Body: body, Unread: unread, Time: message.Time, Flags: computeMessageFlags(labelIDs), LabelIDs: labelIDs, - } - - results, err := p.importRequest([]*pmapi.ImportMsgReq{importMsgReq}) - if err != nil { - return "", errors.Wrap(err, "failed to import messages") - } - if len(results) == 0 { - return "", errors.New("import ended with no result") - } - if results[0].Error != nil { - return "", errors.Wrap(results[0].Error, "failed to import message") - } - return results[0].MessageID, nil + }, nil } func (p *PMAPIProvider) parseMessage(msg Message) (*pmapi.Message, []io.Reader, error) { @@ -218,3 +232,65 @@ func computeMessageFlags(labels []string) (flag int64) { return flag } + +func (p *PMAPIProvider) importMessages(progress *Progress) { + if progress.shouldStop() { + return + } + + importMsgIDs := []string{} + importMsgRequests := []*pmapi.ImportMsgReq{} + for msgID, req := range p.importMsgReqMap { + importMsgIDs = append(importMsgIDs, msgID) + importMsgRequests = append(importMsgRequests, req) + } + + log.WithField("msgIDs", importMsgIDs).WithField("size", p.importMsgReqSize).Debug("Importing messages") + results, err := p.importRequest(importMsgRequests) + + // In case the whole request failed, try to import every message one by one. + if err != nil || len(results) == 0 { + log.WithError(err).Warning("Importing messages failed, trying one by one") + for msgID, req := range p.importMsgReqMap { + importedID, err := p.importMessage(progress, req) + progress.messageImported(msgID, importedID, err) + } + return + } + + // In case request passed but some messages failed, try to import the failed ones alone. + for index, result := range results { + msgID := importMsgIDs[index] + if result.Error != nil { + log.WithError(result.Error).WithField("msg", msgID).Warning("Importing message failed, trying alone") + req := importMsgRequests[index] + importedID, err := p.importMessage(progress, req) + progress.messageImported(msgID, importedID, err) + } else { + progress.messageImported(msgID, result.MessageID, nil) + } + } + + p.importMsgReqMap = map[string]*pmapi.ImportMsgReq{} + p.importMsgReqSize = 0 +} + +func (p *PMAPIProvider) importMessage(progress *Progress, req *pmapi.ImportMsgReq) (importedID string, importedErr error) { + progress.callWrap(func() error { + results, err := p.importRequest([]*pmapi.ImportMsgReq{req}) + if err != nil { + return errors.Wrap(err, "failed to import messages") + } + if len(results) == 0 { + importedErr = errors.New("import ended with no result") + return nil // This should not happen, only when there is bug which means we should skip this one. + } + if results[0].Error != nil { + importedErr = errors.Wrap(results[0].Error, "failed to import message") + return nil // Call passed but API refused this message, skip this one. + } + importedID = results[0].MessageID + return nil + }) + return +} diff --git a/internal/transfer/provider_pmapi_test.go b/internal/transfer/provider_pmapi_test.go index 6d529fae..76ce8327 100644 --- a/internal/transfer/provider_pmapi_test.go +++ b/internal/transfer/provider_pmapi_test.go @@ -178,17 +178,16 @@ func setupPMAPIClientExpectationForExport(m *mocks) { func setupPMAPIClientExpectationForImport(m *mocks) { m.pmapiClient.EXPECT().KeyRingForAddressID(gomock.Any()).Return(m.keyring, nil).AnyTimes() m.pmapiClient.EXPECT().Import(gomock.Any()).DoAndReturn(func(requests []*pmapi.ImportMsgReq) ([]*pmapi.ImportMsgRes, error) { - r.Equal(m.t, 1, len(requests)) - - request := requests[0] - for _, msgID := range []string{"msg1", "msg2"} { - if bytes.Contains(request.Body, []byte(msgID)) { - return []*pmapi.ImportMsgRes{{MessageID: msgID, Error: nil}}, nil + results := []*pmapi.ImportMsgRes{} + for _, request := range requests { + for _, msgID := range []string{"msg1", "msg2"} { + if bytes.Contains(request.Body, []byte(msgID)) { + results = append(results, &pmapi.ImportMsgRes{MessageID: msgID, Error: nil}) + } } } - r.Fail(m.t, "No message found") - return nil, nil - }).Times(2) + return results, nil + }).AnyTimes() } func setupPMAPIClientExpectationForImportDraft(m *mocks) { diff --git a/internal/transfer/provider_pmapi_utils.go b/internal/transfer/provider_pmapi_utils.go index 65bf8817..0633e52e 100644 --- a/internal/transfer/provider_pmapi_utils.go +++ b/internal/transfer/provider_pmapi_utils.go @@ -39,7 +39,7 @@ func (p *PMAPIProvider) ensureConnection(callback func() error) error { return nil } - log.WithField("attempt", i).WithError(callErr).Warning("Call failed, trying reconnect") + log.WithField("attempt", i).WithError(callErr).Warning("API call failed, trying reconnect") err := p.tryReconnect() if err != nil { return err @@ -57,6 +57,7 @@ func (p *PMAPIProvider) tryReconnect() error { } err := p.clientManager.CheckConnection() + log.WithError(err).Debug("Connection check") if err != nil { time.Sleep(pmapiReconnectSleep) previousErr = err diff --git a/internal/transfer/transfer_test.go b/internal/transfer/transfer_test.go index 9a7c0bf7..2d0128f9 100644 --- a/internal/transfer/transfer_test.go +++ b/internal/transfer/transfer_test.go @@ -18,11 +18,10 @@ package transfer import ( - "bytes" "io/ioutil" "testing" - "github.com/ProtonMail/gopenpgp/crypto" + "github.com/ProtonMail/gopenpgp/v2/crypto" transfermocks "github.com/ProtonMail/proton-bridge/internal/transfer/mocks" pmapimocks "github.com/ProtonMail/proton-bridge/pkg/pmapi/mocks" gomock "github.com/golang/mock/gomock" @@ -62,11 +61,12 @@ func newTestKeyring() *crypto.KeyRing { if err != nil { panic(err) } - userKey, err := crypto.ReadArmoredKeyRing(bytes.NewReader(data)) + key, err := crypto.NewKeyFromArmored(string(data)) if err != nil { panic(err) } - if err := userKey.Unlock([]byte("testpassphrase")); err != nil { + userKey, err := crypto.NewKeyRing(key) + if err != nil { panic(err) } return userKey diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 02a46e8b..19c617b4 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -29,9 +29,6 @@ var ( // BuildTime stamp of the build. BuildTime = "" - // AppShortName to make setup. - AppShortName = "bridge" - // DSNSentry client keys to be able to report crashes to Sentry. DSNSentry = "" diff --git a/pkg/message/build.go b/pkg/message/build.go index f25cd09b..290da24e 100644 --- a/pkg/message/build.go +++ b/pkg/message/build.go @@ -27,7 +27,7 @@ import ( "mime/quotedprintable" "net/textproto" - "github.com/ProtonMail/gopenpgp/crypto" + "github.com/ProtonMail/gopenpgp/v2/crypto" "github.com/ProtonMail/proton-bridge/pkg/pmapi" "github.com/emersion/go-textwrapper" openpgperrors "golang.org/x/crypto/openpgp/errors" diff --git a/pkg/pmapi/clientmanager.go b/pkg/pmapi/clientmanager.go index b351347f..a2a91e88 100644 --- a/pkg/pmapi/clientmanager.go +++ b/pkg/pmapi/clientmanager.go @@ -317,9 +317,8 @@ func (cm *ClientManager) CheckConnection() error { retStatus := make(chan error) retAPI := make(chan error) - // Check protonstatus.com without SSL for performance reasons. vpn_status endpoint is fast and - // returns only OK; this endpoint is not known by the public. We check the connection only. - go checkConnection(client, "http://protonstatus.com/vpn_status", retStatus) + // vpn_status endpoint is fast and returns only OK. We check the connection only. + go checkConnection(client, "https://protonstatus.com/vpn_status", retStatus) // Check of API reachability also uses a fast endpoint. go checkConnection(client, cm.GetRootURL()+"/tests/ping", retAPI) @@ -351,7 +350,7 @@ func (cm *ClientManager) CheckConnection() error { func CheckConnection() error { client := &http.Client{Timeout: time.Second * 10} retStatus := make(chan error) - checkConnection(client, "http://protonstatus.com/vpn_status", retStatus) + go checkConnection(client, "https://protonstatus.com/vpn_status", retStatus) return <-retStatus } diff --git a/pkg/updates/updates.go b/pkg/updates/updates.go index 2b0a9607..07463d49 100644 --- a/pkg/updates/updates.go +++ b/pkg/updates/updates.go @@ -27,6 +27,9 @@ import ( "runtime" "strings" + "github.com/ProtonMail/proton-bridge/internal/bridge" + "github.com/ProtonMail/proton-bridge/internal/importexport" + "github.com/ProtonMail/proton-bridge/pkg/constants" "github.com/kardianos/osext" "github.com/sirupsen/logrus" ) @@ -57,7 +60,6 @@ var ( ) type Updates struct { - appName string version string revision string buildTime string @@ -68,26 +70,44 @@ type Updates struct { installerFileBaseName string // File for initial install or manual reinstall. per goos [exe, dmg, sh]. versionFileBaseName string // Text file containing information about current file. per goos [_linux,_darwin,_windows].json (have .sig file). updateFileBaseName string // File for automatic update. per goos [_linux,_darwin,_windows].tgz (have .sig file). - macAppBundleName string // For update procedure. + linuxFileBaseName string // Prefix of linux package names. + macAppBundleName string // Name of Mac app file in the bundle for update procedure. cachedNewerVersion *VersionInfo // To have info about latest version even when the internet connection drops. } -// New inits Updates struct. -// `appName` should be in camelCase format for file names. For installer files is converted to CamelCase. -func New(appName, version, revision, buildTime, releaseNotes, releaseFixedBugs, updateTempDir string) *Updates { +// NewBridge inits Updates struct for bridge. +func NewBridge(updateTempDir string) *Updates { return &Updates{ - appName: appName, - version: version, - revision: revision, - buildTime: buildTime, - releaseNotes: releaseNotes, - releaseFixedBugs: releaseFixedBugs, + version: constants.Version, + revision: constants.Revision, + buildTime: constants.BuildTime, + releaseNotes: bridge.ReleaseNotes, + releaseFixedBugs: bridge.ReleaseFixedBugs, updateTempDir: updateTempDir, - landingPagePath: appName + "/download", - installerFileBaseName: strings.Title(appName) + "-Installer", + landingPagePath: "bridge/download", + installerFileBaseName: "Bridge-Installer", versionFileBaseName: "current_version", - updateFileBaseName: appName + "_upgrade", - macAppBundleName: "ProtonMail " + strings.Title(appName) + ".app", // For update procedure. + updateFileBaseName: "bridge_upgrade", + linuxFileBaseName: "protonmail-bridge", + macAppBundleName: "ProtonMail Bridge.app", + } +} + +// NewImportExport inits Updates struct for import/export. +func NewImportExport(updateTempDir string) *Updates { + return &Updates{ + version: constants.Version, + revision: constants.Revision, + buildTime: constants.BuildTime, + releaseNotes: importexport.ReleaseNotes, + releaseFixedBugs: importexport.ReleaseFixedBugs, + updateTempDir: updateTempDir, + landingPagePath: "blog/import-export-beta/", + installerFileBaseName: "Import-Export-Installer", + versionFileBaseName: "current_version_ie", + updateFileBaseName: "ie_upgrade", + linuxFileBaseName: "protonmail-import-export", + macAppBundleName: "ProtonMail Import-Export.app", } } @@ -165,7 +185,7 @@ func (u *Updates) getLocalVersion(goos string) VersionInfo { } if goos == "linux" { - pkgName := "protonmail-" + u.appName + pkgName := u.linuxFileBaseName pkgRel := "1" pkgBase := strings.Join([]string{Host, DownloadPath, pkgName}, "/") diff --git a/pkg/updates/updates_test.go b/pkg/updates/updates_test.go index 220864d1..c0e89b40 100644 --- a/pkg/updates/updates_test.go +++ b/pkg/updates/updates_test.go @@ -174,5 +174,11 @@ func TestStartUpgrade(t *testing.T) { } func newTestUpdates(version string) *Updates { - return New("bridge", version, "rev123", "42", "• new feature", "• fixed foo", testUpdateDir) + u := NewBridge(testUpdateDir) + u.version = version + u.revision = "rev123" + u.buildTime = "42" + u.releaseNotes = "• new feature" + u.releaseFixedBugs = "• fixed foo" + return u } diff --git a/test/Makefile b/test/Makefile index be965951..0a512f97 100644 --- a/test/Makefile +++ b/test/Makefile @@ -1,25 +1,38 @@ -.PHONY: check-has-go install-godog test test-live test-debug test-live-debug +.PHONY: check-go check-godog install-godog test test-bridge test-ie test-live test-live-bridge test-live-ie test-stage test-debug test-live-debug bench export GO111MODULE=on export BRIDGE_VERSION:=1.3.0-integrationtests export VERBOSITY?=fatal export TEST_DATA=testdata +export TEST_APP?=bridge -check-has-go: +# Tests do not run in parallel. This will overrule user settings. +MAKEFLAGS=-j1 + +check-go: @which go || (echo "Install Go-lang!" && exit 1) - -install-godog: check-has-go +check-godog: + @which godog || $(MAKE) install-godog +install-godog: check-go go get github.com/cucumber/godog/cmd/godog@v0.8.1 -test: - which godog || $(MAKE) install-godog - TEST_ENV=fake TEST_ACCOUNTS=accounts/fake.json godog --tags="~@ignore" $(FEATURES) +test: test-bridge test-ie +test-bridge: FEATURES ?= features/bridge +test-bridge: check-godog + TEST_APP=bridge TEST_ENV=fake TEST_ACCOUNTS=accounts/fake.json godog --tags="~@ignore" $(FEATURES) +test-ie: FEATURES ?= features/ie +test-ie: check-godog + TEST_APP=ie TEST_ENV=fake TEST_ACCOUNTS=accounts/fake.json godog --tags="~@ignore" $(FEATURES) # Doesn't work in parallel! # Provide TEST_ACCOUNTS with your accounts. -test-live: - which godog || $(MAKE) install-godog - TEST_ENV=live godog --tags="~@ignore && ~@ignore-live" $(FEATURES) +test-live: test-live-bridge test-live-ie +test-live-bridge: FEATURES ?= features/bridge +test-live-bridge: check-godog + TEST_APP=bridge TEST_ENV=live godog --tags="~@ignore && ~@ignore-live" $(FEATURES) +test-live-ie: FEATURES ?= features/ie +test-live-ie: check-godog + TEST_APP=ie TEST_ENV=live godog --tags="~@ignore && ~@ignore-live" $(FEATURES) # Doesn't work in parallel! # Provide TEST_ACCOUNTS with your accounts. diff --git a/test/api_actions_test.go b/test/api_actions_test.go new file mode 100644 index 00000000..5bab3ddf --- /dev/null +++ b/test/api_actions_test.go @@ -0,0 +1,45 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge.Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package tests + +import ( + "time" + + "github.com/cucumber/godog" +) + +func APIActionsFeatureContext(s *godog.Suite) { + s.Step(`^the internet connection is lost$`, theInternetConnectionIsLost) + s.Step(`^the internet connection is restored$`, theInternetConnectionIsRestored) + s.Step(`^(\d+) seconds pass$`, secondsPass) +} + +func theInternetConnectionIsLost() error { + ctx.GetPMAPIController().TurnInternetConnectionOff() + return nil +} + +func theInternetConnectionIsRestored() error { + ctx.GetPMAPIController().TurnInternetConnectionOn() + return nil +} + +func secondsPass(seconds int) error { + time.Sleep(time.Duration(seconds) * time.Second) + return nil +} diff --git a/test/api_checks_test.go b/test/api_checks_test.go index 089f7767..b2f6d456 100644 --- a/test/api_checks_test.go +++ b/test/api_checks_test.go @@ -29,6 +29,8 @@ import ( func APIChecksFeatureContext(s *godog.Suite) { s.Step(`^API endpoint "([^"]*)" is called with:$`, apiIsCalledWith) s.Step(`^message is sent with API call:$`, messageIsSentWithAPICall) + s.Step(`^API mailbox "([^"]*)" for "([^"]*)" has messages$`, apiMailboxForUserHasMessages) + s.Step(`^API mailbox "([^"]*)" for address "([^"]*)" of "([^"]*)" has messages$`, apiMailboxForAddressOfUserHasMessages) } func apiIsCalledWith(endpoint string, data *gherkin.DocString) error { @@ -77,3 +79,41 @@ func checkAllRequiredFieldsForSendingMessage(request []byte) bool { } return true } + +func apiMailboxForUserHasMessages(mailboxName, bddUserID string, messages *gherkin.DataTable) error { + return apiMailboxForAddressOfUserHasMessages(mailboxName, "", bddUserID, messages) +} + +func apiMailboxForAddressOfUserHasMessages(mailboxName, bddAddressID, bddUserID string, messages *gherkin.DataTable) error { + account := ctx.GetTestAccountWithAddress(bddUserID, bddAddressID) + if account == nil { + return godog.ErrPending + } + + labelIDs, err := ctx.GetPMAPIController().GetLabelIDs(account.Username(), []string{mailboxName}) + if err != nil { + return internalError(err, "getting label %s for %s", mailboxName, account.Username()) + } + labelID := labelIDs[0] + + pmapiMessages, err := ctx.GetPMAPIController().GetMessages(account.Username(), labelID) + if err != nil { + return err + } + + head := messages.Rows[0].Cells + for _, row := range messages.Rows[1:] { + found, err := messagesContainsMessageRow(account, pmapiMessages, head, row) + if err != nil { + return err + } + if !found { + rowMap := map[string]string{} + for idx, cell := range row.Cells { + rowMap[head[idx].Value] = cell.Value + } + return fmt.Errorf("message %v not found", rowMap) + } + } + return nil +} diff --git a/test/api_setup_test.go b/test/api_setup_test.go new file mode 100644 index 00000000..71232f87 --- /dev/null +++ b/test/api_setup_test.go @@ -0,0 +1,31 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge.Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package tests + +import ( + "github.com/cucumber/godog" +) + +func APISetupFeatureContext(s *godog.Suite) { + s.Step(`^there is no internet connection$`, thereIsNoInternetConnection) +} + +func thereIsNoInternetConnection() error { + ctx.GetPMAPIController().TurnInternetConnectionOff() + return nil +} diff --git a/test/bdd_test.go b/test/bdd_test.go index 17104b51..0b072183 100644 --- a/test/bdd_test.go +++ b/test/bdd_test.go @@ -18,19 +18,27 @@ package tests import ( + "os" + "github.com/ProtonMail/proton-bridge/test/context" "github.com/cucumber/godog" ) +const ( + timeFormat = "2006-01-02T15:04:05" +) + func FeatureContext(s *godog.Suite) { s.BeforeScenario(beforeScenario) s.AfterScenario(afterScenario) + APIActionsFeatureContext(s) APIChecksFeatureContext(s) + APISetupFeatureContext(s) BridgeActionsFeatureContext(s) - BridgeChecksFeatureContext(s) - BridgeSetupFeatureContext(s) + + CommonChecksFeatureContext(s) IMAPActionsAuthFeatureContext(s) IMAPActionsMailboxFeatureContext(s) @@ -45,18 +53,32 @@ func FeatureContext(s *godog.Suite) { StoreActionsFeatureContext(s) StoreChecksFeatureContext(s) StoreSetupFeatureContext(s) + + TransferActionsFeatureContext(s) + TransferChecksFeatureContext(s) + TransferSetupFeatureContext(s) + + UsersActionsFeatureContext(s) + UsersSetupFeatureContext(s) + UsersChecksFeatureContext(s) } var ctx *context.TestContext //nolint[gochecknoglobals] func beforeScenario(scenario interface{}) { - ctx = context.New() + // bridge or ie. With godog 0.10.x and later it can be determined from + // scenario.Uri and its file location. + app := os.Getenv("TEST_APP") + ctx = context.New(app) } func afterScenario(scenario interface{}, err error) { if err != nil { - for _, user := range ctx.GetBridge().GetUsers() { - user.GetStore().TestDumpDB(ctx.GetTestingT()) + for _, user := range ctx.GetUsers().GetUsers() { + store := user.GetStore() + if store != nil { + store.TestDumpDB(ctx.GetTestingT()) + } } } ctx.Cleanup() diff --git a/test/benchmarks/bench_test.go b/test/benchmarks/bench_test.go index c157dc3b..6e6486ea 100644 --- a/test/benchmarks/bench_test.go +++ b/test/benchmarks/bench_test.go @@ -27,7 +27,7 @@ import ( ) func benchTestContext() (*context.TestContext, *mocks.IMAPClient) { - ctx := context.New() + ctx := context.New("bridge") username := "user" account := ctx.GetTestAccount(username) diff --git a/test/bridge_actions_test.go b/test/bridge_actions_test.go index 0c2bb449..5b93ca4d 100644 --- a/test/bridge_actions_test.go +++ b/test/bridge_actions_test.go @@ -18,28 +18,16 @@ package tests import ( - "time" - "github.com/cucumber/godog" ) func BridgeActionsFeatureContext(s *godog.Suite) { s.Step(`^bridge starts$`, bridgeStarts) s.Step(`^bridge syncs "([^"]*)"$`, bridgeSyncsUser) - s.Step(`^"([^"]*)" logs in to bridge$`, userLogsInToBridge) - s.Step(`^"([^"]*)" logs in to bridge with bad password$`, userLogsInToBridgeWithBadPassword) - s.Step(`^"([^"]*)" logs out from bridge$`, userLogsOutFromBridge) - s.Step(`^"([^"]*)" changes the address mode$`, userChangesTheAddressMode) - s.Step(`^user deletes "([^"]*)" from bridge$`, userDeletesUserFromBridge) - s.Step(`^user deletes "([^"]*)" from bridge with cache$`, userDeletesUserFromBridgeWithCache) - s.Step(`^the internet connection is lost$`, theInternetConnectionIsLost) - s.Step(`^the internet connection is restored$`, theInternetConnectionIsRestored) - s.Step(`^(\d+) seconds pass$`, secondsPass) - s.Step(`^"([^"]*)" swaps address "([^"]*)" with address "([^"]*)"$`, swapsAddressWithAddress) } func bridgeStarts() error { - ctx.SetLastBridgeError(ctx.RestartBridge()) + ctx.SetLastError(ctx.RestartBridge()) return nil } @@ -51,113 +39,6 @@ func bridgeSyncsUser(bddUserID string) error { if err := ctx.WaitForSync(account.Username()); err != nil { return internalError(err, "waiting for sync") } - ctx.SetLastBridgeError(ctx.GetTestingError()) + ctx.SetLastError(ctx.GetTestingError()) return nil } - -func userLogsInToBridge(bddUserID string) error { - account := ctx.GetTestAccount(bddUserID) - if account == nil { - return godog.ErrPending - } - ctx.SetLastBridgeError(ctx.LoginUser(account.Username(), account.Password(), account.MailboxPassword())) - return nil -} - -func userLogsInToBridgeWithBadPassword(bddUserID string) error { - account := ctx.GetTestAccount(bddUserID) - if account == nil { - return godog.ErrPending - } - ctx.SetLastBridgeError(ctx.LoginUser(account.Username(), "you shall not pass!", "123")) - return nil -} - -func userLogsOutFromBridge(bddUserID string) error { - account := ctx.GetTestAccount(bddUserID) - if account == nil { - return godog.ErrPending - } - ctx.SetLastBridgeError(ctx.LogoutUser(account.Username())) - return nil -} - -func userChangesTheAddressMode(bddUserID string) error { - account := ctx.GetTestAccount(bddUserID) - if account == nil { - return godog.ErrPending - } - bridgeUser, err := ctx.GetUser(account.Username()) - if err != nil { - return internalError(err, "getting user %s", account.Username()) - } - if err := bridgeUser.SwitchAddressMode(); err != nil { - return err - } - - ctx.EventuallySyncIsFinishedForUsername(account.Username()) - return nil -} - -func userDeletesUserFromBridge(bddUserID string) error { - return deleteUserFromBridge(bddUserID, false) -} - -func userDeletesUserFromBridgeWithCache(bddUserID string) error { - return deleteUserFromBridge(bddUserID, true) -} - -func deleteUserFromBridge(bddUserID string, cache bool) error { - account := ctx.GetTestAccount(bddUserID) - if account == nil { - return godog.ErrPending - } - bridgeUser, err := ctx.GetUser(account.Username()) - if err != nil { - return internalError(err, "getting user %s", account.Username()) - } - return ctx.GetBridge().DeleteUser(bridgeUser.ID(), cache) -} - -func theInternetConnectionIsLost() error { - ctx.GetPMAPIController().TurnInternetConnectionOff() - return nil -} - -func theInternetConnectionIsRestored() error { - ctx.GetPMAPIController().TurnInternetConnectionOn() - return nil -} - -func secondsPass(seconds int) error { - time.Sleep(time.Duration(seconds) * time.Second) - return nil -} - -func swapsAddressWithAddress(bddUserID, bddAddressID1, bddAddressID2 string) error { - account := ctx.GetTestAccount(bddUserID) - if account == nil { - return godog.ErrPending - } - - address1ID := account.GetAddressID(bddAddressID1) - address2ID := account.GetAddressID(bddAddressID2) - addressIDs := make([]string, len(*account.Addresses())) - - var address1Index, address2Index int - for i, v := range *account.Addresses() { - if v.ID == address1ID { - address1Index = i - } - if v.ID == address2ID { - address2Index = i - } - addressIDs[i] = v.ID - } - - addressIDs[address1Index], addressIDs[address2Index] = addressIDs[address2Index], addressIDs[address1Index] - - ctx.ReorderAddresses(account.Username(), bddAddressID1, bddAddressID2) - - return ctx.GetPMAPIController().ReorderAddresses(account.User(), addressIDs) -} diff --git a/test/common_checks_test.go b/test/common_checks_test.go new file mode 100644 index 00000000..bf1690e5 --- /dev/null +++ b/test/common_checks_test.go @@ -0,0 +1,37 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge.Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package tests + +import ( + "github.com/cucumber/godog" + a "github.com/stretchr/testify/assert" +) + +func CommonChecksFeatureContext(s *godog.Suite) { + s.Step(`^last response is "([^"]*)"$`, lastResponseIs) +} + +func lastResponseIs(expectedResponse string) error { + err := ctx.GetLastError() + if expectedResponse == "OK" { + a.NoError(ctx.GetTestingT(), err) + } else { + a.EqualError(ctx.GetTestingT(), err, expectedResponse) + } + return ctx.GetTestingError() +} diff --git a/test/context/bridge.go b/test/context/bridge.go index 2d03de8b..711b5f51 100644 --- a/test/context/bridge.go +++ b/test/context/bridge.go @@ -32,9 +32,10 @@ func (ctx *TestContext) GetBridge() *bridge.Bridge { } // withBridgeInstance creates a bridge instance for use in the test. -// Every TestContext has this by default and thus this doesn't need to be exported. +// TestContext has this by default once called with env variable TEST_APP=bridge. func (ctx *TestContext) withBridgeInstance() { ctx.bridge = newBridgeInstance(ctx.t, ctx.cfg, ctx.credStore, ctx.listener, ctx.clientManager) + ctx.users = ctx.bridge.Users ctx.addCleanupChecked(ctx.bridge.ClearData, "Cleaning bridge data") } @@ -69,13 +70,3 @@ func newBridgeInstance( pref := preferences.New(cfg) return bridge.New(cfg, pref, panicHandler, eventListener, clientManager, credStore) } - -// SetLastBridgeError sets the last error that occurred while executing a bridge action. -func (ctx *TestContext) SetLastBridgeError(err error) { - ctx.bridgeLastError = err -} - -// GetLastBridgeError returns the last error that occurred while executing a bridge action. -func (ctx *TestContext) GetLastBridgeError() error { - return ctx.bridgeLastError -} diff --git a/test/context/config.go b/test/context/config.go index 71dfe89e..1b2e646e 100644 --- a/test/context/config.go +++ b/test/context/config.go @@ -77,6 +77,9 @@ func (c *fakeConfig) GetLogPrefix() string { func (c *fakeConfig) GetPreferencesPath() string { return filepath.Join(c.dir, "prefs.json") } +func (c *fakeConfig) GetTransferDir() string { + return c.dir +} func (c *fakeConfig) GetTLSCertPath() string { return filepath.Join(c.dir, "cert.pem") } diff --git a/test/context/context.go b/test/context/context.go index e6db35c7..b614625c 100644 --- a/test/context/context.go +++ b/test/context/context.go @@ -20,6 +20,8 @@ package context import ( "github.com/ProtonMail/proton-bridge/internal/bridge" + "github.com/ProtonMail/proton-bridge/internal/importexport" + "github.com/ProtonMail/proton-bridge/internal/transfer" "github.com/ProtonMail/proton-bridge/internal/users" "github.com/ProtonMail/proton-bridge/pkg/listener" "github.com/ProtonMail/proton-bridge/pkg/pmapi" @@ -46,10 +48,12 @@ type TestContext struct { pmapiController PMAPIController clientManager *pmapi.ClientManager - // Bridge core related variables. - bridge *bridge.Bridge - bridgeLastError error - credStore users.CredentialsStorer + // Core related variables. + bridge *bridge.Bridge + importExport *importexport.ImportExport + users *users.Users + credStore users.CredentialsStorer + lastError error // IMAP related variables. imapAddr string @@ -63,6 +67,12 @@ type TestContext struct { smtpClients map[string]*mocks.SMTPClient smtpLastResponses map[string]*mocks.SMTPResponse + // Transfer related variables. + transferLocalRootForImport string + transferLocalRootForExport string + transferRemoteIMAPServer *mocks.IMAPServer + transferProgress *transfer.Progress + // These are the cleanup steps executed when Cleanup() is called. cleanupSteps []*Cleaner @@ -71,7 +81,7 @@ type TestContext struct { } // New returns a new test TestContext. -func New() *TestContext { +func New(app string) *TestContext { setLogrusVerbosityFromEnv() cfg := newFakeConfig() @@ -96,8 +106,15 @@ func New() *TestContext { // Ensure that the config is cleaned up after the test is over. ctx.addCleanupChecked(cfg.ClearData, "Cleaning bridge config data") - // Create bridge instance under test. - ctx.withBridgeInstance() + // Create bridge or import/export instance under test. + switch app { + case "bridge": + ctx.withBridgeInstance() + case "ie": + ctx.withImportExportInstance() + default: + panic("unknown app: " + app) + } return ctx } @@ -125,3 +142,13 @@ func (ctx *TestContext) GetTestingT() *bddT { //nolint[golint] func (ctx *TestContext) GetTestingError() error { return ctx.t.getErrors() } + +// SetLastError sets the last error that occurred while executing an action. +func (ctx *TestContext) SetLastError(err error) { + ctx.lastError = err +} + +// GetLastError returns the last error that occurred while executing an action. +func (ctx *TestContext) GetLastError() error { + return ctx.lastError +} diff --git a/test/context/importexport.go b/test/context/importexport.go new file mode 100644 index 00000000..6b468d52 --- /dev/null +++ b/test/context/importexport.go @@ -0,0 +1,48 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge.Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package context + +import ( + "github.com/ProtonMail/proton-bridge/internal/importexport" + "github.com/ProtonMail/proton-bridge/internal/users" + "github.com/ProtonMail/proton-bridge/pkg/listener" +) + +// GetImportExport returns import/export instance. +func (ctx *TestContext) GetImportExport() *importexport.ImportExport { + return ctx.importExport +} + +// withImportExportInstance creates a import/export instance for use in the test. +// TestContext has this by default once called with env variable TEST_APP=ie. +func (ctx *TestContext) withImportExportInstance() { + ctx.importExport = newImportExportInstance(ctx.t, ctx.cfg, ctx.credStore, ctx.listener, ctx.clientManager) + ctx.users = ctx.importExport.Users +} + +// newImportExportInstance creates a new import/export instance configured to use the given config/credstore. +func newImportExportInstance( + t *bddT, + cfg importexport.Configer, + credStore users.CredentialsStorer, + eventListener listener.Listener, + clientManager users.ClientManager, +) *importexport.ImportExport { + panicHandler := &panicHandler{t: t} + return importexport.New(cfg, panicHandler, eventListener, clientManager, credStore) +} diff --git a/test/context/pmapi_controller.go b/test/context/pmapi_controller.go index 769771fe..ea36af35 100644 --- a/test/context/pmapi_controller.go +++ b/test/context/pmapi_controller.go @@ -33,6 +33,7 @@ type PMAPIController interface { GetLabelIDs(username string, labelNames []string) ([]string, error) AddUserMessage(username string, message *pmapi.Message) error GetMessageID(username, messageIndex string) string + GetMessages(username, labelID string) ([]*pmapi.Message, error) ReorderAddresses(user *pmapi.User, addressIDs []string) error PrintCalls() WasCalled(method, path string, expectedRequest []byte) bool diff --git a/test/context/transfer.go b/test/context/transfer.go new file mode 100644 index 00000000..a2e792ae --- /dev/null +++ b/test/context/transfer.go @@ -0,0 +1,91 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge.Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package context + +import ( + "io/ioutil" + "math/rand" + "os" + "strconv" + + "github.com/ProtonMail/proton-bridge/internal/transfer" + "github.com/ProtonMail/proton-bridge/test/mocks" +) + +// SetTransferProgress sets transfer progress. +func (ctx *TestContext) SetTransferProgress(progress *transfer.Progress) { + ctx.transferProgress = progress +} + +// GetTransferProgress returns transfer progress. +func (ctx *TestContext) GetTransferProgress() *transfer.Progress { + return ctx.transferProgress +} + +// GetTransferLocalRootForImport creates temporary root for importing +// if it not exists yet, and returns its path. +func (ctx *TestContext) GetTransferLocalRootForImport() string { + if ctx.transferLocalRootForImport != "" { + return ctx.transferLocalRootForImport + } + root := ctx.createLocalRoot() + ctx.transferLocalRootForImport = root + return root +} + +// GetTransferLocalRootForExport creates temporary root for exporting +// if it not exists yet, and returns its path. +func (ctx *TestContext) GetTransferLocalRootForExport() string { + if ctx.transferLocalRootForExport != "" { + return ctx.transferLocalRootForExport + } + root := ctx.createLocalRoot() + ctx.transferLocalRootForExport = root + return root +} + +func (ctx *TestContext) createLocalRoot() string { + root, err := ioutil.TempDir("", "transfer") + if err != nil { + panic("failed to create temp transfer root: " + err.Error()) + } + + ctx.addCleanupChecked(func() error { + return os.RemoveAll(root) + }, "Cleaning transfer data") + + return root +} + +// GetTransferRemoteIMAPServer creates mocked IMAP server if it not created yet, and returns it. +func (ctx *TestContext) GetTransferRemoteIMAPServer() *mocks.IMAPServer { + if ctx.transferRemoteIMAPServer != nil { + return ctx.transferRemoteIMAPServer + } + + port := 21300 + rand.Intn(100) + ctx.transferRemoteIMAPServer = mocks.NewIMAPServer("user", "pass", "127.0.0.1", strconv.Itoa(port)) + + ctx.transferRemoteIMAPServer.Start() + ctx.addCleanupChecked(func() error { + ctx.transferRemoteIMAPServer.Stop() + return nil + }, "Cleaning transfer IMAP server") + + return ctx.transferRemoteIMAPServer +} diff --git a/test/context/bridge_user.go b/test/context/users.go similarity index 90% rename from test/context/bridge_user.go rename to test/context/users.go index cdfcbd02..934fd213 100644 --- a/test/context/bridge_user.go +++ b/test/context/users.go @@ -30,11 +30,16 @@ import ( "github.com/stretchr/testify/assert" ) +// GetUsers returns users instance. +func (ctx *TestContext) GetUsers() *users.Users { + return ctx.users +} + // LoginUser logs in the user with the given username, password, and mailbox password. func (ctx *TestContext) LoginUser(username, password, mailboxPassword string) (err error) { srp.RandReader = rand.New(rand.NewSource(42)) - client, auth, err := ctx.bridge.Login(username, password) + client, auth, err := ctx.users.Login(username, password) if err != nil { return errors.Wrap(err, "failed to login") } @@ -45,7 +50,7 @@ func (ctx *TestContext) LoginUser(username, password, mailboxPassword string) (e } } - user, err := ctx.bridge.FinishLogin(client, auth, mailboxPassword) + user, err := ctx.users.FinishLogin(client, auth, mailboxPassword) if err != nil { return errors.Wrap(err, "failed to finish login") } @@ -57,7 +62,7 @@ func (ctx *TestContext) LoginUser(username, password, mailboxPassword string) (e // GetUser retrieves the bridge user matching the given query string. func (ctx *TestContext) GetUser(username string) (*users.User, error) { - return ctx.bridge.GetUser(username) + return ctx.users.GetUser(username) } // GetStore retrieves the store for given username. @@ -100,6 +105,9 @@ func (ctx *TestContext) WaitForSync(username string) error { if err != nil { return err } + if store == nil { + return nil + } // First wait for ongoing sync to be done before starting and waiting for new one. ctx.eventuallySyncIsFinished(store) store.TestSync() @@ -121,7 +129,7 @@ func (ctx *TestContext) EventuallySyncIsFinishedForUsername(username string) { // LogoutUser logs out the given user. func (ctx *TestContext) LogoutUser(query string) (err error) { - user, err := ctx.bridge.GetUser(query) + user, err := ctx.users.GetUser(query) if err != nil { return errors.Wrap(err, "failed to get user") } @@ -135,12 +143,12 @@ func (ctx *TestContext) LogoutUser(query string) (err error) { // DeleteUser deletes the given user. func (ctx *TestContext) DeleteUser(query string, deleteStore bool) (err error) { - user, err := ctx.bridge.GetUser(query) + user, err := ctx.users.GetUser(query) if err != nil { return errors.Wrap(err, "failed to get user") } - if err = ctx.bridge.DeleteUser(user.ID(), deleteStore); err != nil { + if err = ctx.users.DeleteUser(user.ID(), deleteStore); err != nil { err = errors.Wrap(err, "failed to delete user") } diff --git a/test/fakeapi/controller_control.go b/test/fakeapi/controller_control.go index fb4ac497..dafaa67c 100644 --- a/test/fakeapi/controller_control.go +++ b/test/fakeapi/controller_control.go @@ -159,3 +159,17 @@ func (ctl *Controller) resetUsers() { func (ctl *Controller) GetMessageID(username, messageIndex string) string { return messageIndex } + +func (ctl *Controller) GetMessages(username, labelID string) ([]*pmapi.Message, error) { + messages := []*pmapi.Message{} + for _, fakeAPI := range ctl.fakeAPIs { + if fakeAPI.username == username { + for _, message := range fakeAPI.messages { + if labelID == "" || message.HasLabelID(labelID) { + messages = append(messages, message) + } + } + } + } + return messages, nil +} diff --git a/test/fakeapi/keyring_userKey b/test/fakeapi/keyring_userKey new file mode 100644 index 00000000..976d2be2 --- /dev/null +++ b/test/fakeapi/keyring_userKey @@ -0,0 +1,62 @@ +-----BEGIN PGP PRIVATE KEY BLOCK----- +Version: OpenPGP.js v4.4.5 +Comment: testpassphrase + +xcLYBFzGzhEBCADBxfqTFMqfQzT77A5tuuhPFwPq8dfC2evs8u1OvTqFbztY +5FOuSxzduyeDqQ1Fx6dKEOKgcYE8t1Uh4VSS7z6bTdY8j9yrL81kCVB46sE1 +OzStzyx/5l7OdH/pM4F+aKslnLvqlw0UeJr+UNizVtOCEUaNfVjPK3cc1ocx +v+36K4RnnyfEtjUW9gDZbhgaF02G5ILHmWmbgM7I+77gCd2wI0EdY9s/JZQ+ +VmkMFqoMdY9PyBchoOIPUkkGQi1SaF4IEzMaAUSbnCYkHHY/SbfDTcR46VGq +cXlkB1rq5xskaUQ9r+giCC/K4pc7bBkI1lQ7ADVuWvdrWnWapK0FO6CfABEB +AAEAB/0YPhPJ0phA/EWviN+16bmGVOZNaVapjt2zMMybWmrtEQv3OeWgO3nP +4cohRi/zaCBCphcm+dxbLhftW7AFi/9PVcR09436MB+oTCQFugpUWw+4TmA5 +BidxTpDxf4X2vH3rquQLBufWL6U7JlPeKAGL1xZ2aCq0DIeOk5D+xTjZizV2 +GIyQRVCLWb+LfDmvvcp3Y94X60KXdBAMuS1ZMKcY3Sl8VAXNB4KQsC/kByzf +6FCB097XZRYV7lvJJQ7+6Wisb3yVi8sEQx2sFm5fAp+0qi3a6zRTEp49r6Hr +gyWViH5zOOpA7DcNwx1Bwhi7GG0tak6EUnnKUNLfOupglcphBADmpXCgT4nc +uSBYTiZSVcB/ICCkTxVsHL1WcXtPK2Ikzussx2n9kb0rapvuC0YLipX9lUkQ +fyeC3jQJeCyN79AkDGkOfWaESueT2hM0Po+RwDgMibKn6yJ1zebz4Lc2J3C9 +oVFcAnql+9KyGsAPn03fyQzDnvhNnJvHJi4Hx8AWoQQA1xLoXeVBjRi0IjjU +E6Mqaq5RLEog4kXRp86VSSEGHBwyIYnDiM//gjseo/CXuVyHwL7UXitp8s1B +D1uE3APrhqUS66fD5pkF+z+RcSqiIv7I76NJ24Cdg38L6seGSjOHrq7/dEeG +K6WqfQUCEjta3yNSg7pXb2wn2WZqKIK+rz8EALZRuMXeql/FtO3Cjb0sv7oT +9dLP4cn1bskGRJ+Vok9lfCERbfXGccoAk3V+qSfpHgKxsebkRbUhf+trOGnw +tW+kBWo/5hYGQuN+A9JogSJViT+nuZyE+x1/rKswDFmlMSdf2GIDARWIV0gc +b1yOEwUmNBSthPcnFXvBr4BG3XTtNPTNLSJhcm9uMjEtM0BzYWRlbWJlLm9y +ZyIgPGFyb24yMS0zQHNhZGVtYmUub3JnPsLAdQQQAQgAHwUCXMbOEQYLCQcI +AwIEFQgKAgMWAgECGQECGwMCHgEACgkQZ/B4v2b2xB6XUgf/dHGRHimyMR78 +QYbEm2cuaEvOtq4a+J6Zv3P4VOWAbvkGWS9LDKSvVi60vq4oYOmF54HgPzur +nA4OtZDf0HKwQK45VZ7CYD693o70jkKPrAAJG3yTsbesfiS7RbFyGKzKJ7EL +nsUIJkfgm/SlKmXU/u8MOBO5Wg7/TcsS33sRWHl90j+9jbhqdl92R+vY/CwC +ieFkQA7/TDv1u+NAalH+Lpkd8AIuEcki+TAogZ7oi/SnofwnoB7BxRm+mIkp +ZZhIDSCaPOzLG8CSZ81d3HVHhqbf8dh0DFKFoUYyKdbOqIkNWWASf+c/ZEme +IWcekY8hqwf/raZ56tGM/bRwYPcotMfC1wRcxs4RAQgAsMb5/ELWmrfPy3ba +5qif+RXhGSbjitATNgHpoPUHrfTC7cn4JWHqehoXLAQpFAoKd+O/ZNpZozK9 +ilpqGUx05yMw06jNQEhYIbgIF4wzPpz02Lp6YeMwdF5LF+Rw83PHdHrA/wRV +/QjL04+kZnN+G5HmzMlhFY+oZSpL+Gp1bTXgtAVDkhCnMB5tP2VwULMGyJ+X +vRYxwTK2CrLjIVZv5n1VYY+caCowU6j/XFqvlCJj+G5oV+UhFOWffaMRXhOh +a64RrhqT1Np7wCLvLMP2wpys9xlMcLQJLqDNxqOTp504V7dm67ncC0fKUsT4 +m4oTktnxKPd6MU+4VYveaLCquwARAQABAAf4u9s7gpGErs1USxmDO9TlyGZK +aBlri8nMf3s+hOJCOo3cRaRHJBfdY6pu/baG6H6JTsWzeY4MHwr6N+dhVIEh +FPMa9EZAjagyc4GugxWGiMVTfU+2AEfdrdynhQKMgXSctnnNCdkRuX0nwqb3 +nlupm1hsz2ze4+Wg0BKSLS0FQdoUbITdJUR69OHr4dNJVHWYI0JSBx4SdhV3 +y9163dDvmc+lW9AEaD53vyZWfzCHZxsR/gI32VmT0z5gn1t8w9AOdXo2lA1H +bf7wh4/qCyujGu64ToZtiEny/GCyM6PofLtiZuJNLw3s/y+B2tKv22aTJ760 ++Gib1xB9WcWjKyrxBADoeCyq+nHGrl0CwOkmjanlFymgo7mnBOXuiFOvGrKk +M1meMU1TI4TEBWkVnDVMcSejgjAf/bX1dtouba1tMAMu7DlaV/0EwbSADRel +RSqEbIzIOys+y9TY/BMI/uCKNyEKHvu1KUXADb+CBpdBpCfMBWDANFlo9xLz +Ajcmu2dyawQAwquwC0VXQcvzfs+Hd5au5XvHdm1KidOiAdu6PH1SrOgenIN4 +lkEjHrJD9jmloO2/GVcxDBB2pmf0B4HEg7DuY9LXBrksP5eSbbRc5+UH1HUv +u82AqQnfNKTd/jae+lLwaOS++ohtwMkkD6W0LdWnHPjyyXg4Oi9zPID3asRu +3PED/3CYyjl6S8GTMY4FNH7Yxu9+NV2xpKE92Hf8K/hnYlmSSVKDCEeOJtLt +BkkcSqY6liCNSMmJdVyAF2GrR+zmDac7UQRssf57oOWtSsGozt0aqJXuspMT +6aB+P1UhZ8Ly9rWZNiJ0jwyfnQNOLCYDaqjFmiSpqrNnJ2Q1Xge3+k80P9DC +wF8EGAEIAAkFAlzGzhECGwwACgkQZ/B4v2b2xB5wlwgAjZA1zdv5irFjyWVo +4/itONtyO1NbdpyYpcct7vD0oV+a4wahQP0J3Kk1GhZ5tvAoZF/jakQQOM5o +GjUYpXAGnr09Mv9EiQ2pDwXc2yq0WfXnGxNrpzOqdtV+IqY9NYkl55Tme7x+ +WRvrkPSUeUsyEGvxwR1stdv8eg9jUmxdl8Io3PYoFJJlrM/6aXeC1r3KOj7q +XAnR0XHJ+QBSNKCWLlQv5hui9BKfcLiVKFK/dNhs82nRyhPr4sWFw6MTqdAK +4zkn7l0jmy6Evi1AiiGPiHPnxeNErnofOIEh4REQj00deZADHrixTLtx2FuR +uaSC3IcBmBsj1fNb4eYXElILjQ== +=fMOl +-----END PGP PRIVATE KEY BLOCK----- \ No newline at end of file diff --git a/test/fakeapi/messages.go b/test/fakeapi/messages.go index 3fa76943..557fdf85 100644 --- a/test/fakeapi/messages.go +++ b/test/fakeapi/messages.go @@ -20,6 +20,7 @@ package fakeapi import ( "bytes" "fmt" + "io/ioutil" "net/mail" "time" @@ -67,7 +68,7 @@ func (api *FakePMAPI) ListMessages(filter *pmapi.MessagesFilter) ([]*pmapi.Messa for idx := 0; idx < len(api.messages); idx++ { var message *pmapi.Message - if !*filter.Desc { + if filter.Desc == nil || !*filter.Desc { message = api.messages[idx] if filter.BeginID == "" || message.ID == filter.BeginID { skipByIDBegin = false @@ -81,7 +82,7 @@ func (api *FakePMAPI) ListMessages(filter *pmapi.MessagesFilter) ([]*pmapi.Messa if skipByIDBegin || skipByIDEnd { continue } - if !*filter.Desc { + if filter.Desc == nil || !*filter.Desc { if message.ID == filter.EndID { skipByIDEnd = true } @@ -189,36 +190,60 @@ func (api *FakePMAPI) SendMessage(messageID string, sendMessageRequest *pmapi.Se } func (api *FakePMAPI) Import(importMessageRequests []*pmapi.ImportMsgReq) ([]*pmapi.ImportMsgRes, error) { + if err := api.checkAndRecordCall(POST, "/import", importMessageRequests); err != nil { + return nil, err + } msgRes := []*pmapi.ImportMsgRes{} for _, msgReq := range importMessageRequests { - mailMessage, err := mail.ReadMessage(bytes.NewBuffer(msgReq.Body)) + message, err := api.generateMessageFromImportRequest(msgReq) if err != nil { msgRes = append(msgRes, &pmapi.ImportMsgRes{ Error: err, }) - } - messageID := api.controller.messageIDGenerator.next("") - message := &pmapi.Message{ - ID: messageID, - AddressID: msgReq.AddressID, - Sender: &mail.Address{Address: mailMessage.Header.Get("From")}, - ToList: []*mail.Address{{Address: mailMessage.Header.Get("To")}}, - Subject: mailMessage.Header.Get("Subject"), - Unread: msgReq.Unread, - LabelIDs: msgReq.LabelIDs, - Body: string(msgReq.Body), - Flags: msgReq.Flags, - Time: msgReq.Time, + continue } msgRes = append(msgRes, &pmapi.ImportMsgRes{ Error: nil, - MessageID: messageID, + MessageID: message.ID, }) api.addMessage(message) } return msgRes, nil } +func (api *FakePMAPI) generateMessageFromImportRequest(msgReq *pmapi.ImportMsgReq) (*pmapi.Message, error) { + mailMessage, err := mail.ReadMessage(bytes.NewBuffer(msgReq.Body)) + if err != nil { + return nil, err + } + body, err := ioutil.ReadAll(mailMessage.Body) + if err != nil { + return nil, err + } + sender, err := mail.ParseAddress(mailMessage.Header.Get("From")) + if err != nil { + return nil, err + } + toList, err := mail.ParseAddressList(mailMessage.Header.Get("To")) + if err != nil { + return nil, err + } + messageID := api.controller.messageIDGenerator.next("") + return &pmapi.Message{ + ID: messageID, + AddressID: msgReq.AddressID, + Sender: sender, + ToList: toList, + Subject: mailMessage.Header.Get("Subject"), + Unread: msgReq.Unread, + LabelIDs: append(msgReq.LabelIDs, pmapi.AllMailLabel), + Body: string(body), + Header: mailMessage.Header, + Flags: msgReq.Flags, + Time: msgReq.Time, + }, nil +} + func (api *FakePMAPI) addMessage(message *pmapi.Message) { api.messages = append(api.messages, message) api.addEventMessage(pmapi.EventCreate, message) diff --git a/test/features/imap/auth.feature b/test/features/bridge/imap/auth.feature similarity index 97% rename from test/features/imap/auth.feature rename to test/features/bridge/imap/auth.feature index 8898b50a..3191d1e3 100644 --- a/test/features/imap/auth.feature +++ b/test/features/bridge/imap/auth.feature @@ -29,16 +29,16 @@ Feature: IMAP auth Scenario: Authenticates with freshly logged-out user Given there is connected user "user" - When "user" logs out from bridge + When "user" logs out And IMAP client authenticates "user" Then IMAP response is "IMAP error: NO account is logged out, use the app to login again" Scenario: Authenticates user which was re-logged in Given there is connected user "user" - When "user" logs out from bridge + When "user" logs out And IMAP client authenticates "user" Then IMAP response is "IMAP error: NO account is logged out, use the app to login again" - When "user" logs in to bridge + When "user" logs in And IMAP client authenticates "user" Then IMAP response is "OK" When IMAP client selects "INBOX" diff --git a/test/features/imap/idle/basic.feature b/test/features/bridge/imap/idle/basic.feature similarity index 100% rename from test/features/imap/idle/basic.feature rename to test/features/bridge/imap/idle/basic.feature diff --git a/test/features/imap/idle/two_users.feature b/test/features/bridge/imap/idle/two_users.feature similarity index 100% rename from test/features/imap/idle/two_users.feature rename to test/features/bridge/imap/idle/two_users.feature diff --git a/test/features/imap/mailbox/create.feature b/test/features/bridge/imap/mailbox/create.feature similarity index 100% rename from test/features/imap/mailbox/create.feature rename to test/features/bridge/imap/mailbox/create.feature diff --git a/test/features/imap/mailbox/delete.feature b/test/features/bridge/imap/mailbox/delete.feature similarity index 100% rename from test/features/imap/mailbox/delete.feature rename to test/features/bridge/imap/mailbox/delete.feature diff --git a/test/features/imap/mailbox/info.feature b/test/features/bridge/imap/mailbox/info.feature similarity index 100% rename from test/features/imap/mailbox/info.feature rename to test/features/bridge/imap/mailbox/info.feature diff --git a/test/features/imap/mailbox/list.feature b/test/features/bridge/imap/mailbox/list.feature similarity index 100% rename from test/features/imap/mailbox/list.feature rename to test/features/bridge/imap/mailbox/list.feature diff --git a/test/features/imap/mailbox/rename.feature b/test/features/bridge/imap/mailbox/rename.feature similarity index 100% rename from test/features/imap/mailbox/rename.feature rename to test/features/bridge/imap/mailbox/rename.feature diff --git a/test/features/imap/mailbox/select.feature b/test/features/bridge/imap/mailbox/select.feature similarity index 100% rename from test/features/imap/mailbox/select.feature rename to test/features/bridge/imap/mailbox/select.feature diff --git a/test/features/imap/mailbox/status.feature b/test/features/bridge/imap/mailbox/status.feature similarity index 100% rename from test/features/imap/mailbox/status.feature rename to test/features/bridge/imap/mailbox/status.feature diff --git a/test/features/imap/message/copy.feature b/test/features/bridge/imap/message/copy.feature similarity index 100% rename from test/features/imap/message/copy.feature rename to test/features/bridge/imap/message/copy.feature diff --git a/test/features/imap/message/create.feature b/test/features/bridge/imap/message/create.feature similarity index 100% rename from test/features/imap/message/create.feature rename to test/features/bridge/imap/message/create.feature diff --git a/test/features/imap/message/delete.feature b/test/features/bridge/imap/message/delete.feature similarity index 100% rename from test/features/imap/message/delete.feature rename to test/features/bridge/imap/message/delete.feature diff --git a/test/features/imap/message/fetch.feature b/test/features/bridge/imap/message/fetch.feature similarity index 100% rename from test/features/imap/message/fetch.feature rename to test/features/bridge/imap/message/fetch.feature diff --git a/test/features/imap/message/import.feature b/test/features/bridge/imap/message/import.feature similarity index 100% rename from test/features/imap/message/import.feature rename to test/features/bridge/imap/message/import.feature diff --git a/test/features/imap/message/move.feature b/test/features/bridge/imap/message/move.feature similarity index 100% rename from test/features/imap/message/move.feature rename to test/features/bridge/imap/message/move.feature diff --git a/test/features/imap/message/search.feature b/test/features/bridge/imap/message/search.feature similarity index 100% rename from test/features/imap/message/search.feature rename to test/features/bridge/imap/message/search.feature diff --git a/test/features/imap/message/update.feature b/test/features/bridge/imap/message/update.feature similarity index 100% rename from test/features/imap/message/update.feature rename to test/features/bridge/imap/message/update.feature diff --git a/test/features/smtp/auth.feature b/test/features/bridge/smtp/auth.feature similarity index 100% rename from test/features/smtp/auth.feature rename to test/features/bridge/smtp/auth.feature diff --git a/test/features/smtp/send/bcc.feature b/test/features/bridge/smtp/send/bcc.feature similarity index 100% rename from test/features/smtp/send/bcc.feature rename to test/features/bridge/smtp/send/bcc.feature diff --git a/test/features/smtp/send/failures.feature b/test/features/bridge/smtp/send/failures.feature similarity index 100% rename from test/features/smtp/send/failures.feature rename to test/features/bridge/smtp/send/failures.feature diff --git a/test/features/smtp/send/html.feature b/test/features/bridge/smtp/send/html.feature similarity index 100% rename from test/features/smtp/send/html.feature rename to test/features/bridge/smtp/send/html.feature diff --git a/test/features/smtp/send/html_att.feature b/test/features/bridge/smtp/send/html_att.feature similarity index 100% rename from test/features/smtp/send/html_att.feature rename to test/features/bridge/smtp/send/html_att.feature diff --git a/test/features/smtp/send/plain.feature b/test/features/bridge/smtp/send/plain.feature similarity index 100% rename from test/features/smtp/send/plain.feature rename to test/features/bridge/smtp/send/plain.feature diff --git a/test/features/smtp/send/plain_att.feature b/test/features/bridge/smtp/send/plain_att.feature similarity index 100% rename from test/features/smtp/send/plain_att.feature rename to test/features/bridge/smtp/send/plain_att.feature diff --git a/test/features/smtp/send/same_message.feature b/test/features/bridge/smtp/send/same_message.feature similarity index 100% rename from test/features/smtp/send/same_message.feature rename to test/features/bridge/smtp/send/same_message.feature diff --git a/test/features/smtp/send/two_messages.feature b/test/features/bridge/smtp/send/two_messages.feature similarity index 100% rename from test/features/smtp/send/two_messages.feature rename to test/features/bridge/smtp/send/two_messages.feature diff --git a/test/features/bridge/addressmode.feature b/test/features/bridge/users/addressmode.feature similarity index 98% rename from test/features/bridge/addressmode.feature rename to test/features/bridge/users/addressmode.feature index f99c4f03..be8e25f8 100644 --- a/test/features/bridge/addressmode.feature +++ b/test/features/bridge/users/addressmode.feature @@ -26,7 +26,7 @@ Feature: Address mode Scenario: Switch address mode from combined to split mode Given there is "userMoreAddresses" in "combined" address mode When "userMoreAddresses" changes the address mode - Then bridge response is "OK" + Then last response is "OK" And "userMoreAddresses" has address mode in "split" mode And mailbox "Folders/mbox" for address "primary" of "userMoreAddresses" has messages | from | to | subject | @@ -38,7 +38,7 @@ Feature: Address mode Scenario: Switch address mode from split to combined mode Given there is "userMoreAddresses" in "split" address mode When "userMoreAddresses" changes the address mode - Then bridge response is "OK" + Then last response is "OK" And "userMoreAddresses" has address mode in "combined" mode And mailbox "Folders/mbox" for address "primary" of "userMoreAddresses" has messages | from | to | subject | diff --git a/test/features/bridge/deleteuser.feature b/test/features/bridge/users/delete.feature similarity index 62% rename from test/features/bridge/deleteuser.feature rename to test/features/bridge/users/delete.feature index 61fa6d3b..770ddf6d 100644 --- a/test/features/bridge/deleteuser.feature +++ b/test/features/bridge/users/delete.feature @@ -1,36 +1,36 @@ Feature: Delete user Scenario: Deleting connected user Given there is connected user "user" - When user deletes "user" from bridge - Then bridge response is "OK" + When user deletes "user" + Then last response is "OK" And "user" has database file Scenario: Deleting connected user with cache Given there is connected user "user" - When user deletes "user" from bridge with cache - Then bridge response is "OK" + When user deletes "user" with cache + Then last response is "OK" And "user" does not have database file Scenario: Deleting connected user without database file Given there is connected user "user" And there is no database file for "user" - When user deletes "user" from bridge with cache - Then bridge response is "OK" + When user deletes "user" with cache + Then last response is "OK" Scenario: Deleting disconnected user Given there is disconnected user "user" - When user deletes "user" from bridge - Then bridge response is "OK" + When user deletes "user" + Then last response is "OK" And "user" has database file Scenario: Deleting disconnected user with cache Given there is disconnected user "user" - When user deletes "user" from bridge with cache - Then bridge response is "OK" + When user deletes "user" with cache + Then last response is "OK" And "user" does not have database file Scenario: Deleting disconnected user without database file Given there is disconnected user "user" And there is no database file for "user" - When user deletes "user" from bridge with cache - Then bridge response is "OK" + When user deletes "user" with cache + Then last response is "OK" diff --git a/test/features/bridge/login.feature b/test/features/bridge/users/login.feature similarity index 59% rename from test/features/bridge/login.feature rename to test/features/bridge/users/login.feature index a74cd1d1..8f89bee1 100644 --- a/test/features/bridge/login.feature +++ b/test/features/bridge/users/login.feature @@ -1,47 +1,47 @@ -Feature: Login to bridge for the first time - Scenario: Normal bridge login +Feature: Login for the first time + Scenario: Normal login Given there is user "user" - When "user" logs in to bridge - Then bridge response is "OK" + When "user" logs in + Then last response is "OK" And "user" is connected And "user" has database file And "user" has running event loop Scenario: Login with bad username - When "user" logs in to bridge with bad password - Then bridge response is "failed to login: Incorrect login credentials. Please try again" + When "user" logs in with bad password + Then last response is "failed to login: Incorrect login credentials. Please try again" Scenario: Login with bad password Given there is user "user" - When "user" logs in to bridge with bad password - Then bridge response is "failed to login: Incorrect login credentials. Please try again" + When "user" logs in with bad password + Then last response is "failed to login: Incorrect login credentials. Please try again" Scenario: Login without internet connection Given there is no internet connection - When "user" logs in to bridge - Then bridge response is "failed to login: cannot reach the server" + When "user" logs in + Then last response is "failed to login: cannot reach the server" @ignore-live Scenario: Login user with 2FA Given there is user "user2fa" - When "user2fa" logs in to bridge - Then bridge response is "OK" + When "user2fa" logs in + Then last response is "OK" And "user2fa" is connected And "user2fa" has database file And "user2fa" has running event loop Scenario: Login user with capital letters in address Given there is user "userAddressWithCapitalLetter" - When "userAddressWithCapitalLetter" logs in to bridge - Then bridge response is "OK" + When "userAddressWithCapitalLetter" logs in + Then last response is "OK" And "userAddressWithCapitalLetter" is connected And "userAddressWithCapitalLetter" has database file And "userAddressWithCapitalLetter" has running event loop Scenario: Login user with more addresses Given there is user "userMoreAddresses" - When "userMoreAddresses" logs in to bridge - Then bridge response is "OK" + When "userMoreAddresses" logs in + Then last response is "OK" And "userMoreAddresses" is connected And "userMoreAddresses" has database file And "userMoreAddresses" has running event loop @@ -49,8 +49,8 @@ Feature: Login to bridge for the first time @ignore-live Scenario: Login user with disabled primary address Given there is user "userDisabledPrimaryAddress" - When "userDisabledPrimaryAddress" logs in to bridge - Then bridge response is "OK" + When "userDisabledPrimaryAddress" logs in + Then last response is "OK" And "userDisabledPrimaryAddress" is connected And "userDisabledPrimaryAddress" has database file And "userDisabledPrimaryAddress" has running event loop @@ -58,9 +58,9 @@ Feature: Login to bridge for the first time Scenario: Login two users Given there is user "user" And there is user "userMoreAddresses" - When "user" logs in to bridge - Then bridge response is "OK" + When "user" logs in + Then last response is "OK" And "user" is connected - When "userMoreAddresses" logs in to bridge - Then bridge response is "OK" + When "userMoreAddresses" logs in + Then last response is "OK" And "userMoreAddresses" is connected diff --git a/test/features/bridge/relogin.feature b/test/features/bridge/users/relogin.feature similarity index 70% rename from test/features/bridge/relogin.feature rename to test/features/bridge/users/relogin.feature index aeb4dd06..e0b43d79 100644 --- a/test/features/bridge/relogin.feature +++ b/test/features/bridge/users/relogin.feature @@ -1,9 +1,9 @@ -Feature: Re-login to bridge +Feature: Re-login Scenario: Re-login with connected user and database file Given there is connected user "user" And there is database file for "user" - When "user" logs in to bridge - Then bridge response is "failed to finish login: user is already connected" + When "user" logs in + Then last response is "failed to finish login: user is already connected" And "user" is connected And "user" has running event loop @@ -11,8 +11,8 @@ Feature: Re-login to bridge Scenario: Re-login with connected user and no database file Given there is connected user "user" And there is no database file for "user" - When "user" logs in to bridge - Then bridge response is "failed to finish login: user is already connected" + When "user" logs in + Then last response is "failed to finish login: user is already connected" And "user" is connected And "user" has database file And "user" has running event loop @@ -20,16 +20,16 @@ Feature: Re-login to bridge Scenario: Re-login with disconnected user and database file Given there is disconnected user "user" And there is database file for "user" - When "user" logs in to bridge - Then bridge response is "OK" + When "user" logs in + Then last response is "OK" And "user" is connected And "user" has running event loop Scenario: Re-login with disconnected user and no database file Given there is disconnected user "user" And there is no database file for "user" - When "user" logs in to bridge - Then bridge response is "OK" + When "user" logs in + Then last response is "OK" And "user" is connected And "user" has database file And "user" has running event loop diff --git a/test/features/bridge/sync.feature b/test/features/bridge/users/sync.feature similarity index 97% rename from test/features/bridge/sync.feature rename to test/features/bridge/users/sync.feature index 6824200d..09c616d8 100644 --- a/test/features/bridge/sync.feature +++ b/test/features/bridge/users/sync.feature @@ -28,7 +28,7 @@ Feature: Sync bridge Scenario: Sync in combined mode And there is "userMoreAddresses" in "combined" address mode When bridge syncs "userMoreAddresses" - Then bridge response is "OK" + Then last response is "OK" And "userMoreAddresses" has the following messages | mailboxes | messages | | INBOX | 1101 | @@ -43,7 +43,7 @@ Feature: Sync bridge Scenario: Sync in split mode And there is "userMoreAddresses" in "split" address mode When bridge syncs "userMoreAddresses" - Then bridge response is "OK" + Then last response is "OK" And "userMoreAddresses" has the following messages | address | mailboxes | messages | | primary | INBOX | 1001 | diff --git a/test/features/ie/transfer/export_eml.feature b/test/features/ie/transfer/export_eml.feature new file mode 100644 index 00000000..50a7917c --- /dev/null +++ b/test/features/ie/transfer/export_eml.feature @@ -0,0 +1,43 @@ +Feature: Export to EML files + Background: + Given there is connected user "user" + And there is "user" with mailbox "Folders/Foo" + And there are messages in mailbox "INBOX" for "user" + | from | to | subject | time | + | bridgetest@pm.test | test@protonmail.com | hello | 2020-01-01T12:00:00 | + And there are messages in mailbox "Folders/Foo" for "user" + | from | to | subject | time | + | foo@example.com | bridgetest@protonmail.com | one | 2020-01-01T12:00:00 | + | bar@example.com | bridgetest@protonmail.com | two | 2020-01-01T13:00:00 | + | bar@example.com | bridgetest@protonmail.com | three | 2020-01-01T12:30:00 | + + Scenario: Export all + When user "user" exports to EML files + Then progress result is "OK" + # Every message is also in All Mail. + And transfer exported 8 messages + And transfer imported 8 messages + And transfer failed for 0 messages + And transfer exported messages + | folder | from | to | subject | time | + | Inbox | bridgetest@pm.test | test@protonmail.com | hello | 2020-01-01T12:00:00 | + | Foo | foo@example.com | bridgetest@protonmail.com | one | 2020-01-01T12:00:00 | + | Foo | bar@example.com | bridgetest@protonmail.com | two | 2020-01-01T13:00:00 | + | Foo | bar@example.com | bridgetest@protonmail.com | three | 2020-01-01T12:30:00 | + | All Mail | bridgetest@pm.test | test@protonmail.com | hello | 2020-01-01T12:00:00 | + | All Mail | foo@example.com | bridgetest@protonmail.com | one | 2020-01-01T12:00:00 | + | All Mail | bar@example.com | bridgetest@protonmail.com | two | 2020-01-01T13:00:00 | + | All Mail | bar@example.com | bridgetest@protonmail.com | three | 2020-01-01T12:30:00 | + + Scenario: Export only Foo with time limit + When user "user" exports to EML files with rules + | source | target | from | to | + | Foo | | 2020-01-01T12:10:00 | 2020-01-01T13:00:00 | + Then progress result is "OK" + And transfer exported 2 messages + And transfer imported 2 messages + And transfer failed for 0 messages + And transfer exported messages + | folder | from | to | subject | time | + | Foo | bar@example.com | bridgetest@protonmail.com | two | 2020-01-01T13:00:00 | + | Foo | bar@example.com | bridgetest@protonmail.com | three | 2020-01-01T12:30:00 | diff --git a/test/features/ie/transfer/export_mbox.feature b/test/features/ie/transfer/export_mbox.feature new file mode 100644 index 00000000..a0143eed --- /dev/null +++ b/test/features/ie/transfer/export_mbox.feature @@ -0,0 +1,43 @@ +Feature: Export to MBOX files + Background: + Given there is connected user "user" + And there is "user" with mailbox "Folders/Foo" + And there are messages in mailbox "INBOX" for "user" + | from | to | subject | time | + | bridgetest@pm.test | test@protonmail.com | hello | 2020-01-01T12:00:00 | + And there are messages in mailbox "Folders/Foo" for "user" + | from | to | subject | time | + | foo@example.com | bridgetest@protonmail.com | one | 2020-01-01T12:00:00 | + | bar@example.com | bridgetest@protonmail.com | two | 2020-01-01T13:00:00 | + | bar@example.com | bridgetest@protonmail.com | three | 2020-01-01T12:30:00 | + + Scenario: Export all + When user "user" exports to MBOX files + Then progress result is "OK" + # Every message is also in All Mail. + And transfer exported 8 messages + And transfer imported 8 messages + And transfer failed for 0 messages + And transfer exported messages + | folder | from | to | subject | time | + | Inbox | bridgetest@pm.test | test@protonmail.com | hello | 2020-01-01T12:00:00 | + | Foo | foo@example.com | bridgetest@protonmail.com | one | 2020-01-01T12:00:00 | + | Foo | bar@example.com | bridgetest@protonmail.com | two | 2020-01-01T13:00:00 | + | Foo | bar@example.com | bridgetest@protonmail.com | three | 2020-01-01T12:30:00 | + | All Mail | bridgetest@pm.test | test@protonmail.com | hello | 2020-01-01T12:00:00 | + | All Mail | foo@example.com | bridgetest@protonmail.com | one | 2020-01-01T12:00:00 | + | All Mail | bar@example.com | bridgetest@protonmail.com | two | 2020-01-01T13:00:00 | + | All Mail | bar@example.com | bridgetest@protonmail.com | three | 2020-01-01T12:30:00 | + + Scenario: Export only Foo with time limit + When user "user" exports to MBOX files with rules + | source | target | from | to | + | Foo | | 2020-01-01T12:10:00 | 2020-01-01T13:00:00 | + Then progress result is "OK" + And transfer exported 2 messages + And transfer imported 2 messages + And transfer failed for 0 messages + And transfer exported messages + | folder | from | to | subject | time | + | Foo | bar@example.com | bridgetest@protonmail.com | two | 2020-01-01T13:00:00 | + | Foo | bar@example.com | bridgetest@protonmail.com | three | 2020-01-01T12:30:00 | diff --git a/test/features/ie/transfer/import_eml.feature b/test/features/ie/transfer/import_eml.feature new file mode 100644 index 00000000..5bd1cae9 --- /dev/null +++ b/test/features/ie/transfer/import_eml.feature @@ -0,0 +1,60 @@ +Feature: Import from EML files + Background: + Given there is connected user "user" + And there is "user" with mailbox "Folders/Foo" + And there is "user" with mailbox "Folders/Bar" + And there are EML files + | file | from | to | subject | time | + | Foo/one.eml | foo@example.com | bridgetest@protonmail.com | one | 2020-01-01T12:00:00 | + | Foo/two.eml | bar@example.com | bridgetest@protonmail.com | two | 2020-01-01T13:00:00 | + | Sub/Foo/three.eml | bar@example.com | bridgetest@protonmail.com | three | 2020-01-01T12:30:00 | + And there is EML file "Inbox/hello.eml" + """ + Subject: hello + From: Bridge Test + To: Internal Bridge + + hello + + """ + + Scenario: Import all + When user "user" imports local files + Then progress result is "OK" + And transfer exported 4 messages + And transfer imported 4 messages + And transfer failed for 0 messages + And API mailbox "INBOX" for "user" has messages + | from | to | subject | + | bridgetest@pm.test | test@protonmail.com | hello | + And API mailbox "Folders/Foo" for "user" has messages + | from | to | subject | + | foo@example.com | bridgetest@protonmail.com | one | + | bar@example.com | bridgetest@protonmail.com | two | + | bar@example.com | bridgetest@protonmail.com | three | + + Scenario: Import only Foo to Bar with time limit + When user "user" imports local files with rules + | source | target | from | to | + | Foo | Bar | 2020-01-01T12:10:00 | 2020-01-01T13:00:00 | + Then progress result is "OK" + And transfer exported 2 messages + And transfer imported 2 messages + And transfer failed for 0 messages + And API mailbox "Folders/Bar" for "user" has messages + | from | to | subject | + | bar@example.com | bridgetest@protonmail.com | two | + | bar@example.com | bridgetest@protonmail.com | three | + + Scenario: Import broken EML message + Given there is EML file "Broken/broken.eml" + """ + Content-type: image/png + """ + When user "user" imports local files with rules + | source | target | + | Broken | Foo | + Then progress result is "OK" + And transfer exported 1 messages + And transfer imported 0 messages + And transfer failed for 1 messages diff --git a/test/features/ie/transfer/import_export.feature b/test/features/ie/transfer/import_export.feature new file mode 100644 index 00000000..5cd5d29e --- /dev/null +++ b/test/features/ie/transfer/import_export.feature @@ -0,0 +1,49 @@ +Feature: Import/Export + Background: + Given there is connected user "user" + And there is "user" with mailbox "Folders/Foo" + And there is "user" with mailbox "Folders/Bar" + + Scenario: EML -> PM -> EML + Given there are EML files + | file | from | to | subject | time | + | Inbox/hello.eml | bridgetest@pm.test | test@protonmail.com | hello | 2020-01-01T12:00:00 | + | Foo/one.eml | foo@example.com | bridgetest@protonmail.com | one | 2020-01-01T12:00:00 | + | Foo/two.eml | bar@example.com | bridgetest@protonmail.com | two | 2020-01-01T13:00:00 | + | Sub/Foo/three.eml | bar@example.com | bridgetest@protonmail.com | three | 2020-01-01T12:30:00 | + + When user "user" imports local files + Then progress result is "OK" + And transfer failed for 0 messages + And transfer imported 4 messages + + When user "user" exports to EML files + Then progress result is "OK" + And transfer failed for 0 messages + # Every message is also in All Mail. + And transfer imported 8 messages + + And exported messages match the original ones + + Scenario: MBOX -> PM -> MBOX + Given there is MBOX file "Inbox.mbox" with messages + | from | to | subject | time | + | bridgetest@pm.test | test@protonmail.com | hello | 2020-01-01T12:00:00 | + And there is MBOX file "Foo.mbox" with messages + | from | to | subject | time | + | foo@example.com | bridgetest@protonmail.com | one | 2020-01-01T12:00:00 | + | bar@example.com | bridgetest@protonmail.com | two | 2020-01-01T13:00:00 | + | bar@example.com | bridgetest@protonmail.com | three | 2020-01-01T12:30:00 | + + When user "user" imports local files + Then progress result is "OK" + And transfer failed for 0 messages + And transfer imported 4 messages + + When user "user" exports to MBOX files + Then progress result is "OK" + And transfer failed for 0 messages + # Every message is also in All Mail. + And transfer imported 8 messages + + And exported messages match the original ones diff --git a/test/features/ie/transfer/import_imap.feature b/test/features/ie/transfer/import_imap.feature new file mode 100644 index 00000000..f3766348 --- /dev/null +++ b/test/features/ie/transfer/import_imap.feature @@ -0,0 +1,79 @@ +Feature: Import from IMAP server + Background: + Given there is connected user "user" + And there is "user" with mailbox "Folders/Foo" + And there is "user" with mailbox "Folders/Bar" + And there are IMAP mailboxes + | name | + | Inbox | + | Foo | + | Broken | + And there are IMAP messages + | mailbox | seqnum | uid | from | to | subject | time | + | Foo | 1 | 12 | foo@example.com | bridgetest@protonmail.com | one | 2020-01-01T12:00:00 | + | Foo | 2 | 14 | bar@example.com | bridgetest@protonmail.com | two | 2020-01-01T13:00:00 | + | Foo | 3 | 15 | bar@example.com | bridgetest@protonmail.com | three | 2020-01-01T12:30:00 | + And there is IMAP message in mailbox "Inbox" with seq 1, uid 42, time "2020-01-01T12:34:56" and subject "hello" + """ + Subject: hello + From: Bridge Test + To: Internal Bridge + + hello + + """ + + Scenario: Import all + When user "user" imports remote messages + Then progress result is "OK" + And transfer exported 4 messages + And transfer imported 4 messages + And transfer failed for 0 messages + And API mailbox "INBOX" for "user" has messages + | from | to | subject | + | bridgetest@pm.test | test@protonmail.com | hello | + And API mailbox "Folders/Foo" for "user" has messages + | from | to | subject | + | foo@example.com | bridgetest@protonmail.com | one | + | bar@example.com | bridgetest@protonmail.com | two | + | bar@example.com | bridgetest@protonmail.com | three | + + Scenario: Import only Foo to Bar with time limit + When user "user" imports remote messages with rules + | source | target | from | to | + | Foo | Bar | 2020-01-01T12:10:00 | 2020-01-01T13:00:00 | + Then progress result is "OK" + And transfer exported 2 messages + And transfer imported 2 messages + And transfer failed for 0 messages + And API mailbox "Folders/Bar" for "user" has messages + | from | to | subject | + | bar@example.com | bridgetest@protonmail.com | two | + | bar@example.com | bridgetest@protonmail.com | three | + + # Note we need to have message which we can parse and use in go-imap + # but which has problem on our side. Used example with missing boundary + # is real example which we want to solve one day. Probabl this test + # can be removed once we import any time of message or switch is to + # something we will never allow. + Scenario: Import broken message + Given there is IMAP message in mailbox "Broken" with seq 1, uid 42, time "2020-01-01T12:34:56" and subject "broken" + """ + Subject: missing boundary end + Content-Type: multipart/related; boundary=boundary + + --boundary + Content-Disposition: inline + Content-Transfer-Encoding: quoted-printable + Content-Type: text/plain; charset=utf-8 + + body + + """ + When user "user" imports remote messages with rules + | source | target | + | Broken | Foo | + Then progress result is "OK" + And transfer exported 1 messages + And transfer imported 0 messages + And transfer failed for 1 messages diff --git a/test/features/ie/transfer/import_mbox.feature b/test/features/ie/transfer/import_mbox.feature new file mode 100644 index 00000000..90388992 --- /dev/null +++ b/test/features/ie/transfer/import_mbox.feature @@ -0,0 +1,64 @@ +Feature: Import from MBOX files + Background: + Given there is connected user "user" + And there is "user" with mailbox "Folders/Foo" + And there is "user" with mailbox "Folders/Bar" + And there is MBOX file "Foo.mbox" with messages + | from | to | subject | time | + | foo@example.com | bridgetest@protonmail.com | one | 2020-01-01T12:00:00 | + | bar@example.com | bridgetest@protonmail.com | two | 2020-01-01T13:00:00 | + And there is MBOX file "Sub/Foo.mbox" with messages + | from | to | subject | time | + | bar@example.com | bridgetest@protonmail.com | three | 2020-01-01T12:30:00 | + And there is MBOX file "Inbox.mbox" + """ + From bridgetest@pm.test Thu Feb 20 20:20:20 2020 + Subject: hello + From: Bridge Test + To: Internal Bridge + + hello + + """ + + Scenario: Import all + When user "user" imports local files + Then progress result is "OK" + And transfer exported 4 messages + And transfer imported 4 messages + And transfer failed for 0 messages + And API mailbox "INBOX" for "user" has messages + | from | to | subject | + | bridgetest@pm.test | test@protonmail.com | hello | + And API mailbox "Folders/Foo" for "user" has messages + | from | to | subject | + | foo@example.com | bridgetest@protonmail.com | one | + | bar@example.com | bridgetest@protonmail.com | two | + | bar@example.com | bridgetest@protonmail.com | three | + + Scenario: Import only Foo to Bar with time limit + When user "user" imports local files with rules + | source | target | from | to | + | Foo | Bar | 2020-01-01T12:10:00 | 2020-01-01T13:00:00 | + Then progress result is "OK" + And transfer exported 2 messages + And transfer imported 2 messages + And transfer failed for 0 messages + And API mailbox "Folders/Bar" for "user" has messages + | from | to | subject | + | bar@example.com | bridgetest@protonmail.com | two | + | bar@example.com | bridgetest@protonmail.com | three | + + Scenario: Import broken message + Given there is MBOX file "Broken.mbox" + """ + From bridgetest@pm.test Thu Feb 20 20:20:20 2020 + Content-type: image/png + """ + When user "user" imports local files with rules + | source | target | + | Broken | Foo | + Then progress result is "OK" + And transfer exported 1 messages + And transfer imported 0 messages + And transfer failed for 1 messages diff --git a/test/features/ie/users/delete.feature b/test/features/ie/users/delete.feature new file mode 100644 index 00000000..34ffe4b0 --- /dev/null +++ b/test/features/ie/users/delete.feature @@ -0,0 +1,20 @@ +Feature: Delete user + Scenario: Deleting connected user + Given there is connected user "user" + When user deletes "user" + Then last response is "OK" + + Scenario: Deleting connected user with cache + Given there is connected user "user" + When user deletes "user" with cache + Then last response is "OK" + + Scenario: Deleting disconnected user + Given there is disconnected user "user" + When user deletes "user" + Then last response is "OK" + + Scenario: Deleting disconnected user with cache + Given there is disconnected user "user" + When user deletes "user" with cache + Then last response is "OK" diff --git a/test/features/ie/users/login.feature b/test/features/ie/users/login.feature new file mode 100644 index 00000000..e74cc7b2 --- /dev/null +++ b/test/features/ie/users/login.feature @@ -0,0 +1,56 @@ +Feature: Login for the first time + Scenario: Normal login + Given there is user "user" + When "user" logs in + Then last response is "OK" + And "user" is connected + + Scenario: Login with bad username + When "user" logs in with bad password + Then last response is "failed to login: Incorrect login credentials. Please try again" + + Scenario: Login with bad password + Given there is user "user" + When "user" logs in with bad password + Then last response is "failed to login: Incorrect login credentials. Please try again" + + Scenario: Login without internet connection + Given there is no internet connection + When "user" logs in + Then last response is "failed to login: cannot reach the server" + + @ignore-live + Scenario: Login user with 2FA + Given there is user "user2fa" + When "user2fa" logs in + Then last response is "OK" + And "user2fa" is connected + + Scenario: Login user with capital letters in address + Given there is user "userAddressWithCapitalLetter" + When "userAddressWithCapitalLetter" logs in + Then last response is "OK" + And "userAddressWithCapitalLetter" is connected + + Scenario: Login user with more addresses + Given there is user "userMoreAddresses" + When "userMoreAddresses" logs in + Then last response is "OK" + And "userMoreAddresses" is connected + + @ignore-live + Scenario: Login user with disabled primary address + Given there is user "userDisabledPrimaryAddress" + When "userDisabledPrimaryAddress" logs in + Then last response is "OK" + And "userDisabledPrimaryAddress" is connected + + Scenario: Login two users + Given there is user "user" + And there is user "userMoreAddresses" + When "user" logs in + Then last response is "OK" + And "user" is connected + When "userMoreAddresses" logs in + Then last response is "OK" + And "userMoreAddresses" is connected diff --git a/test/features/ie/users/relogin.feature b/test/features/ie/users/relogin.feature new file mode 100644 index 00000000..d55e3eea --- /dev/null +++ b/test/features/ie/users/relogin.feature @@ -0,0 +1,12 @@ +Feature: Re-login + Scenario: Re-login with connected user + Given there is connected user "user" + When "user" logs in + Then last response is "failed to finish login: user is already connected" + And "user" is connected + + Scenario: Re-login with disconnected user + Given there is disconnected user "user" + When "user" logs in + Then last response is "OK" + And "user" is connected diff --git a/test/liveapi/messages.go b/test/liveapi/messages.go index f064cffd..1d682aaa 100644 --- a/test/liveapi/messages.go +++ b/test/liveapi/messages.go @@ -135,3 +135,31 @@ func (ctl *Controller) GetMessageID(username, messageIndex string) string { } return ctl.messageIDsByUsername[username][idx-1] } + +func (ctl *Controller) GetMessages(username, labelID string) ([]*pmapi.Message, error) { + client, ok := ctl.pmapiByUsername[username] + if !ok { + return nil, fmt.Errorf("user %s does not exist", username) + } + + page := 0 + messages := []*pmapi.Message{} + + for { + // ListMessages returns empty result, not error, asking for page out of range. + pageMessages, _, err := client.ListMessages(&pmapi.MessagesFilter{ + Page: page, + PageSize: 150, + LabelID: labelID, + }) + if err != nil { + return nil, errors.Wrap(err, "failed to list messages") + } + messages = append(messages, pageMessages...) + if len(pageMessages) < 150 { + break + } + } + + return messages, nil +} diff --git a/test/mocks/imap.go b/test/mocks/imap_client.go similarity index 100% rename from test/mocks/imap.go rename to test/mocks/imap_client.go diff --git a/test/mocks/imap_response.go b/test/mocks/imap_response.go index 75c36e7c..5945f405 100644 --- a/test/mocks/imap_response.go +++ b/test/mocks/imap_response.go @@ -18,13 +18,13 @@ package mocks import ( - "bufio" "fmt" "io" "regexp" "strings" "time" + "github.com/emersion/go-imap" "github.com/pkg/errors" a "github.com/stretchr/testify/assert" ) @@ -37,7 +37,7 @@ type IMAPResponse struct { done bool } -func (ir *IMAPResponse) sendCommand(reqTag string, reqIndex int, command string, debug *debug, conn io.Writer, response *bufio.Reader) { +func (ir *IMAPResponse) sendCommand(reqTag string, reqIndex int, command string, debug *debug, conn io.Writer, response imap.StringReader) { defer func() { ir.done = true }() tstart := time.Now() diff --git a/test/mocks/imap_server.go b/test/mocks/imap_server.go new file mode 100644 index 00000000..b47f5ce2 --- /dev/null +++ b/test/mocks/imap_server.go @@ -0,0 +1,227 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge.Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package mocks + +import ( + "fmt" + "net" + "strings" + "time" + + "github.com/emersion/go-imap" + imapbackend "github.com/emersion/go-imap/backend" + imapserver "github.com/emersion/go-imap/server" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" +) + +type IMAPServer struct { + Username string + Password string + Host string + Port string + + mailboxes []string + messages map[string][]*imap.Message // Key is mailbox. + server *imapserver.Server +} + +func NewIMAPServer(username, password, host, port string) *IMAPServer { + return &IMAPServer{ + Username: username, + Password: password, + Host: host, + Port: port, + + mailboxes: []string{}, + messages: map[string][]*imap.Message{}, + } +} + +func (s *IMAPServer) AddMailbox(mailboxName string) { + s.mailboxes = append(s.mailboxes, mailboxName) + s.messages[strings.ToLower(mailboxName)] = []*imap.Message{} +} + +func (s *IMAPServer) AddMessage(mailboxName string, message *imap.Message) { + mailboxName = strings.ToLower(mailboxName) + s.messages[mailboxName] = append(s.messages[mailboxName], message) +} + +func (s *IMAPServer) Start() { + server := imapserver.New(&IMAPBackend{server: s}) + server.Addr = net.JoinHostPort(s.Host, s.Port) + server.AllowInsecureAuth = true + server.ErrorLog = logrus.WithField("pkg", "imap-server") + server.Debug = logrus.WithField("pkg", "imap-server").WriterLevel(logrus.DebugLevel) + server.AutoLogout = 30 * time.Minute + + s.server = server + + go func() { + err := server.ListenAndServe() + logrus.WithError(err).Warn("IMAP server stopped") + }() + + time.Sleep(100 * time.Millisecond) +} + +func (s *IMAPServer) Stop() { + _ = s.server.Close() +} + +type IMAPBackend struct { + server *IMAPServer +} + +func (b *IMAPBackend) Login(connInfo *imap.ConnInfo, username, password string) (imapbackend.User, error) { + if username != b.server.Username || password != b.server.Password { + return nil, errors.New("invalid credentials") + } + return &IMAPUser{ + server: b.server, + username: username, + }, nil +} + +type IMAPUser struct { + server *IMAPServer + username string +} + +func (u *IMAPUser) Username() string { + return u.username +} + +func (u *IMAPUser) ListMailboxes(subscribed bool) ([]imapbackend.Mailbox, error) { + mailboxes := []imapbackend.Mailbox{} + for _, mailboxName := range u.server.mailboxes { + mailboxes = append(mailboxes, &IMAPMailbox{ + server: u.server, + name: mailboxName, + }) + } + return mailboxes, nil +} + +func (u *IMAPUser) GetMailbox(name string) (imapbackend.Mailbox, error) { + name = strings.ToLower(name) + _, ok := u.server.messages[name] + if !ok { + return nil, fmt.Errorf("mailbox %s not found", name) + } + return &IMAPMailbox{ + server: u.server, + name: name, + }, nil +} + +func (u *IMAPUser) CreateMailbox(name string) error { + return errors.New("not supported: create mailbox") +} + +func (u *IMAPUser) DeleteMailbox(name string) error { + return errors.New("not supported: delete mailbox") +} + +func (u *IMAPUser) RenameMailbox(existingName, newName string) error { + return errors.New("not supported: rename mailbox") +} + +func (u *IMAPUser) Logout() error { + return nil +} + +type IMAPMailbox struct { + server *IMAPServer + name string + attributes []string +} + +func (m *IMAPMailbox) Name() string { + return m.name +} + +func (m *IMAPMailbox) Info() (*imap.MailboxInfo, error) { + return &imap.MailboxInfo{ + Name: m.name, + Attributes: m.attributes, + }, nil +} + +func (m *IMAPMailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, error) { + status := imap.NewMailboxStatus(m.name, items) + status.UidValidity = 1 + status.Messages = uint32(len(m.server.messages[m.name])) + return status, nil +} + +func (m *IMAPMailbox) SetSubscribed(subscribed bool) error { + return errors.New("not supported: set subscribed") +} + +func (m *IMAPMailbox) Check() error { + return errors.New("not supported: check") +} + +func (m *IMAPMailbox) ListMessages(uid bool, seqset *imap.SeqSet, items []imap.FetchItem, ch chan<- *imap.Message) error { + defer func() { + close(ch) + }() + + for index, message := range m.server.messages[m.name] { + seqNum := uint32(index + 1) + var id uint32 + if uid { + id = message.Uid + } else { + id = seqNum + } + if seqset.Contains(id) { + msg := imap.NewMessage(seqNum, items) + msg.Envelope = message.Envelope + msg.BodyStructure = message.BodyStructure + msg.Body = message.Body + msg.Size = message.Size + msg.Uid = message.Uid + + ch <- msg + } + } + return nil +} + +func (m *IMAPMailbox) SearchMessages(uid bool, criteria *imap.SearchCriteria) ([]uint32, error) { + return nil, errors.New("not supported: search") +} + +func (m *IMAPMailbox) CreateMessage(flags []string, date time.Time, body imap.Literal) error { + return errors.New("not supported: create") +} + +func (m *IMAPMailbox) UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, operation imap.FlagsOp, flags []string) error { + return errors.New("not supported: update flags") +} + +func (m *IMAPMailbox) CopyMessages(uid bool, seqset *imap.SeqSet, dest string) error { + return errors.New("not supported: copy") +} + +func (m *IMAPMailbox) Expunge() error { + return errors.New("not supported: expunge") +} diff --git a/test/store_checks_test.go b/test/store_checks_test.go index f90ea344..bb5aebcd 100644 --- a/test/store_checks_test.go +++ b/test/store_checks_test.go @@ -172,12 +172,12 @@ func messagesContainsMessageRow(account *accounts.TestAccount, allMessages []*pm matches := true for n, cell := range row.Cells { switch head[n].Value { - case "from": + case "from": //nolint[goconst] address := ctx.EnsureAddress(account.Username(), cell.Value) if !areAddressesSame(message.Sender.Address, address) { matches = false } - case "to": + case "to": //nolint[goconst] for _, address := range strings.Split(cell.Value, ",") { address = ctx.EnsureAddress(account.Username(), address) for _, to := range message.ToList { @@ -197,7 +197,7 @@ func messagesContainsMessageRow(account *accounts.TestAccount, allMessages []*pm } } } - case "subject": + case "subject": //nolint[goconst] expectedSubject := cell.Value if expectedSubject == "" { expectedSubject = "(No Subject)" @@ -205,7 +205,7 @@ func messagesContainsMessageRow(account *accounts.TestAccount, allMessages []*pm if message.Subject != expectedSubject { matches = false } - case "body": + case "body": //nolint[goconst] if message.Body != cell.Value { matches = false } @@ -238,7 +238,7 @@ func areAddressesSame(first, second string) bool { if err != nil { return false } - return firstAddress.String() == secondAddress.String() + return firstAddress.Address == secondAddress.Address } func messagesInMailboxForUserIsMarkedAsRead(messageIDs, mailboxName, bddUserID string) error { diff --git a/test/store_setup_test.go b/test/store_setup_test.go index 9db508e0..ff456c36 100644 --- a/test/store_setup_test.go +++ b/test/store_setup_test.go @@ -22,6 +22,7 @@ import ( "net/mail" "strconv" "strings" + "time" "github.com/ProtonMail/proton-bridge/pkg/pmapi" "github.com/cucumber/godog" @@ -63,6 +64,9 @@ func thereIsUserWithMailbox(bddUserID, mailboxName string) error { if err != nil { return internalError(err, "getting store of %s", account.Username()) } + if store == nil { + return nil + } return internalError(store.RebuildMailboxes(), "rebuilding mailboxes") } @@ -122,6 +126,12 @@ func thereAreMessagesInMailboxesForAddressOfUser(mailboxNames, bddAddressID, bdd if cell.Value == "true" { message.LabelIDs = append(message.LabelIDs, "10") } + case "time": //nolint[goconst] + date, err := time.Parse(timeFormat, cell.Value) + if err != nil { + return internalError(err, "parsing time") + } + message.Time = date.Unix() default: return fmt.Errorf("unexpected column name: %s", head[n].Value) } diff --git a/test/transfer_actions_test.go b/test/transfer_actions_test.go new file mode 100644 index 00000000..ae049c2e --- /dev/null +++ b/test/transfer_actions_test.go @@ -0,0 +1,227 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge.Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package tests + +import ( + "fmt" + "time" + + "github.com/ProtonMail/proton-bridge/internal/transfer" + "github.com/cucumber/godog" + "github.com/cucumber/godog/gherkin" +) + +func TransferActionsFeatureContext(s *godog.Suite) { + s.Step(`^user "([^"]*)" imports local files$`, userImportsLocalFiles) + s.Step(`^user "([^"]*)" imports local files with rules$`, userImportsLocalFilesWithRules) + s.Step(`^user "([^"]*)" imports local files to address "([^"]*)"$`, userImportsLocalFilesToAddress) + s.Step(`^user "([^"]*)" imports local files to address "([^"]*)" with rules$`, userImportsLocalFilesToAddressWithRules) + s.Step(`^user "([^"]*)" imports remote messages$`, userImportsRemoteMessages) + s.Step(`^user "([^"]*)" imports remote messages with rules$`, userImportsRemoteMessagesWithRules) + s.Step(`^user "([^"]*)" imports remote messages to address "([^"]*)"$`, userImportsRemoteMessagesToAddress) + s.Step(`^user "([^"]*)" imports remote messages to address "([^"]*)" with rules$`, userImportsRemoteMessagesToAddressWithRules) + s.Step(`^user "([^"]*)" exports to EML files$`, userExportsToEMLFiles) + s.Step(`^user "([^"]*)" exports to EML files with rules$`, userExportsToEMLFilesWithRules) + s.Step(`^user "([^"]*)" exports address "([^"]*)" to EML files$`, userExportsAddressToEMLFiles) + s.Step(`^user "([^"]*)" exports address "([^"]*)" to EML files with rules$`, userExportsAddressToEMLFilesWithRules) + s.Step(`^user "([^"]*)" exports to MBOX files$`, userExportsToMBOXFiles) + s.Step(`^user "([^"]*)" exports to MBOX files with rules$`, userExportsToMBOXFilesWithRules) + s.Step(`^user "([^"]*)" exports address "([^"]*)" to MBOX files$`, userExportsAddressToMBOXFiles) + s.Step(`^user "([^"]*)" exports address "([^"]*)" to MBOX files with rules$`, userExportsAddressToMBOXFilesWithRules) +} + +// Local import. + +func userImportsLocalFiles(bddUserID string) error { + return userImportsLocalFilesToAddressWithRules(bddUserID, "", nil) +} + +func userImportsLocalFilesWithRules(bddUserID string, rules *gherkin.DataTable) error { + return userImportsLocalFilesToAddressWithRules(bddUserID, "", rules) +} + +func userImportsLocalFilesToAddress(bddUserID, bddAddressID string) error { + return userImportsLocalFilesToAddressWithRules(bddUserID, bddAddressID, nil) +} + +func userImportsLocalFilesToAddressWithRules(bddUserID, bddAddressID string, rules *gherkin.DataTable) error { + return doTransfer(bddUserID, bddAddressID, rules, func(address string) (*transfer.Transfer, error) { + path := ctx.GetTransferLocalRootForImport() + return ctx.GetImportExport().GetLocalImporter(address, path) + }) +} + +// Remote import. + +func userImportsRemoteMessages(bddUserID string) error { + return userImportsRemoteMessagesToAddressWithRules(bddUserID, "", nil) +} + +func userImportsRemoteMessagesWithRules(bddUserID string, rules *gherkin.DataTable) error { + return userImportsRemoteMessagesToAddressWithRules(bddUserID, "", rules) +} + +func userImportsRemoteMessagesToAddress(bddUserID, bddAddressID string) error { + return userImportsRemoteMessagesToAddressWithRules(bddUserID, bddAddressID, nil) +} + +func userImportsRemoteMessagesToAddressWithRules(bddUserID, bddAddressID string, rules *gherkin.DataTable) error { + return doTransfer(bddUserID, bddAddressID, rules, func(address string) (*transfer.Transfer, error) { + imapServer := ctx.GetTransferRemoteIMAPServer() + return ctx.GetImportExport().GetRemoteImporter(address, imapServer.Username, imapServer.Password, imapServer.Host, imapServer.Port) + }) +} + +// EML export. + +func userExportsToEMLFiles(bddUserID string) error { + return userExportsAddressToEMLFilesWithRules(bddUserID, "", nil) +} + +func userExportsToEMLFilesWithRules(bddUserID string, rules *gherkin.DataTable) error { + return userExportsAddressToEMLFilesWithRules(bddUserID, "", rules) +} + +func userExportsAddressToEMLFiles(bddUserID, bddAddressID string) error { + return userExportsAddressToEMLFilesWithRules(bddUserID, bddAddressID, nil) +} + +func userExportsAddressToEMLFilesWithRules(bddUserID, bddAddressID string, rules *gherkin.DataTable) error { + return doTransfer(bddUserID, bddAddressID, rules, func(address string) (*transfer.Transfer, error) { + path := ctx.GetTransferLocalRootForExport() + return ctx.GetImportExport().GetEMLExporter(address, path) + }) +} + +// MBOX export. + +func userExportsToMBOXFiles(bddUserID string) error { + return userExportsAddressToMBOXFilesWithRules(bddUserID, "", nil) +} + +func userExportsToMBOXFilesWithRules(bddUserID string, rules *gherkin.DataTable) error { + return userExportsAddressToMBOXFilesWithRules(bddUserID, "", rules) +} + +func userExportsAddressToMBOXFiles(bddUserID, bddAddressID string) error { + return userExportsAddressToMBOXFilesWithRules(bddUserID, bddAddressID, nil) +} + +func userExportsAddressToMBOXFilesWithRules(bddUserID, bddAddressID string, rules *gherkin.DataTable) error { + return doTransfer(bddUserID, bddAddressID, rules, func(address string) (*transfer.Transfer, error) { + path := ctx.GetTransferLocalRootForExport() + return ctx.GetImportExport().GetMBOXExporter(address, path) + }) +} + +// Helpers. + +func doTransfer(bddUserID, bddAddressID string, rules *gherkin.DataTable, getTransferrer func(string) (*transfer.Transfer, error)) error { + account := ctx.GetTestAccountWithAddress(bddUserID, bddAddressID) + if account == nil { + return godog.ErrPending + } + transferrer, err := getTransferrer(account.Address()) + if err != nil { + return internalError(err, "failed to init transfer") + } + if err := setRules(transferrer, rules); err != nil { + return internalError(err, "failed to set rules") + } + progress := transferrer.Start() + ctx.SetTransferProgress(progress) + return nil +} + +func setRules(transferrer *transfer.Transfer, rules *gherkin.DataTable) error { + if rules == nil { + return nil + } + + transferrer.ResetRules() + + allSourceMailboxes, err := transferrer.SourceMailboxes() + if err != nil { + return internalError(err, "failed to get source mailboxes") + } + allTargetMailboxes, err := transferrer.TargetMailboxes() + if err != nil { + return internalError(err, "failed to get target mailboxes") + } + + head := rules.Rows[0].Cells + for _, row := range rules.Rows[1:] { + source := "" + target := "" + fromTime := int64(0) + toTime := int64(0) + for n, cell := range row.Cells { + switch head[n].Value { + case "source": + source = cell.Value + case "target": + target = cell.Value + case "from": + date, err := time.Parse(timeFormat, cell.Value) + if err != nil { + return internalError(err, "failed to parse from time") + } + fromTime = date.Unix() + case "to": + date, err := time.Parse(timeFormat, cell.Value) + if err != nil { + return internalError(err, "failed to parse to time") + } + toTime = date.Unix() + default: + return fmt.Errorf("unexpected column name: %s", head[n].Value) + } + } + + sourceMailbox, err := getMailboxByName(allSourceMailboxes, source) + if err != nil { + return internalError(err, "failed to match source mailboxes") + } + + // Empty target means the same as source. Useful for exports. + targetMailboxes := []transfer.Mailbox{} + if target == "" { + targetMailboxes = append(targetMailboxes, sourceMailbox) + } else { + targetMailbox, err := getMailboxByName(allTargetMailboxes, target) + if err != nil { + return internalError(err, "failed to match target mailboxes") + } + targetMailboxes = append(targetMailboxes, targetMailbox) + } + + if err := transferrer.SetRule(sourceMailbox, targetMailboxes, fromTime, toTime); err != nil { + return internalError(err, "failed to set rule") + } + } + return nil +} + +func getMailboxByName(mailboxes []transfer.Mailbox, name string) (transfer.Mailbox, error) { + for _, mailbox := range mailboxes { + if mailbox.Name == name { + return mailbox, nil + } + } + return transfer.Mailbox{}, fmt.Errorf("mailbox %s not found", name) +} diff --git a/test/transfer_checks_test.go b/test/transfer_checks_test.go new file mode 100644 index 00000000..7452f8eb --- /dev/null +++ b/test/transfer_checks_test.go @@ -0,0 +1,267 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge.Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package tests + +import ( + "fmt" + "io" + "io/ioutil" + "net/mail" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/cucumber/godog" + "github.com/cucumber/godog/gherkin" + "github.com/emersion/go-mbox" + "github.com/emersion/go-message" + "github.com/pkg/errors" + a "github.com/stretchr/testify/assert" +) + +func TransferChecksFeatureContext(s *godog.Suite) { + s.Step(`^progress result is "([^"]*)"$`, progressFinishedWith) + s.Step(`^transfer exported (\d+) messages$`, transferExportedNumberOfMessages) + s.Step(`^transfer imported (\d+) messages$`, transferImportedNumberOfMessages) + s.Step(`^transfer failed for (\d+) messages$`, transferFailedForNumberOfMessages) + s.Step(`^transfer exported messages$`, transferExportedMessages) + s.Step(`^exported messages match the original ones$`, exportedMessagesMatchTheOriginalOnes) +} + +func progressFinishedWith(wantResponse string) error { + progress := ctx.GetTransferProgress() + // Wait till transport is finished. + for range progress.GetUpdateChannel() { + } + + err := progress.GetFatalError() + if wantResponse == "OK" { + a.NoError(ctx.GetTestingT(), err) + } else { + a.EqualError(ctx.GetTestingT(), err, wantResponse) + } + return ctx.GetTestingError() +} + +func transferExportedNumberOfMessages(wantCount int) error { + progress := ctx.GetTransferProgress() + _, _, exported, _, _ := progress.GetCounts() //nolint[dogsled] + a.Equal(ctx.GetTestingT(), uint(wantCount), exported) + return ctx.GetTestingError() +} + +func transferImportedNumberOfMessages(wantCount int) error { + progress := ctx.GetTransferProgress() + _, imported, _, _, _ := progress.GetCounts() //nolint[dogsled] + a.Equal(ctx.GetTestingT(), uint(wantCount), imported) + return ctx.GetTestingError() +} + +func transferFailedForNumberOfMessages(wantCount int) error { + progress := ctx.GetTransferProgress() + failedMessages := progress.GetFailedMessages() + a.Equal(ctx.GetTestingT(), wantCount, len(failedMessages), "failed messages: %v", failedMessages) + return ctx.GetTestingError() +} + +func transferExportedMessages(messages *gherkin.DataTable) error { + expectedMessages := map[string][]MessageAttributes{} + + head := messages.Rows[0].Cells + for _, row := range messages.Rows[1:] { + folder := "" + msg := MessageAttributes{} + + for n, cell := range row.Cells { + switch head[n].Value { + case "folder": + folder = cell.Value + case "subject": + msg.subject = cell.Value + case "from": + msg.from = cell.Value + case "to": + msg.to = []string{cell.Value} + case "time": + date, err := time.Parse(timeFormat, cell.Value) + if err != nil { + return internalError(err, "failed to parse time") + } + msg.date = date.Unix() + default: + return fmt.Errorf("unexpected column name: %s", head[n].Value) + } + } + + expectedMessages[folder] = append(expectedMessages[folder], msg) + sort.Sort(BySubject(expectedMessages[folder])) + } + + exportRoot := ctx.GetTransferLocalRootForExport() + exportedMessages, err := readMessages(exportRoot) + if err != nil { + return errors.Wrap(err, "scanning exported messages") + } + + a.Equal(ctx.GetTestingT(), expectedMessages, exportedMessages) + return ctx.GetTestingError() +} + +func exportedMessagesMatchTheOriginalOnes() error { + importRoot := ctx.GetTransferLocalRootForImport() + exportRoot := ctx.GetTransferLocalRootForExport() + + importMessages, err := readMessages(importRoot) + if err != nil { + return errors.Wrap(err, "scanning messages for import") + } + exportMessages, err := readMessages(exportRoot) + if err != nil { + return errors.Wrap(err, "scanning exported messages") + } + delete(exportMessages, "All Mail") // Ignore All Mail. + + a.Equal(ctx.GetTestingT(), importMessages, exportMessages) + return ctx.GetTestingError() +} + +func readMessages(root string) (map[string][]MessageAttributes, error) { + files, err := ioutil.ReadDir(root) + if err != nil { + return nil, err + } + + messagesPerLabel := map[string][]MessageAttributes{} + for _, file := range files { + if !file.IsDir() { + fileReader, err := os.Open(filepath.Join(root, file.Name())) + if err != nil { + return nil, errors.Wrap(err, "opening file") + } + + if filepath.Ext(file.Name()) == ".eml" { + label := filepath.Base(root) + msg, err := readMessageAttributes(fileReader) + if err != nil { + return nil, err + } + messagesPerLabel[label] = append(messagesPerLabel[label], msg) + sort.Sort(BySubject(messagesPerLabel[label])) + } else if filepath.Ext(file.Name()) == ".mbox" { + label := strings.TrimSuffix(file.Name(), ".mbox") + mboxReader := mbox.NewReader(fileReader) + for { + msgReader, err := mboxReader.NextMessage() + if err == io.EOF { + break + } else if err != nil { + return nil, errors.Wrap(err, "reading next message") + } + msg, err := readMessageAttributes(msgReader) + if err != nil { + return nil, err + } + messagesPerLabel[label] = append(messagesPerLabel[label], msg) + } + sort.Sort(BySubject(messagesPerLabel[label])) + } + } else { + subfolderRoot := filepath.Join(root, file.Name()) + subfolderMessagesPerLabel, err := readMessages(subfolderRoot) + if err != nil { + return nil, err + } + for key, value := range subfolderMessagesPerLabel { + messagesPerLabel[key] = append(messagesPerLabel[key], value...) + sort.Sort(BySubject(messagesPerLabel[key])) + } + } + } + return messagesPerLabel, nil +} + +type MessageAttributes struct { + subject string + from string + to []string + date int64 +} + +func readMessageAttributes(fileReader io.Reader) (MessageAttributes, error) { + entity, err := message.Read(fileReader) + if err != nil { + return MessageAttributes{}, errors.Wrap(err, "reading file") + } + date, err := parseTime(entity.Header.Get("date")) + if err != nil { + return MessageAttributes{}, errors.Wrap(err, "parsing date") + } + from, err := parseAddress(entity.Header.Get("from")) + if err != nil { + return MessageAttributes{}, errors.Wrap(err, "parsing from") + } + to, err := parseAddresses(entity.Header.Get("to")) + if err != nil { + return MessageAttributes{}, errors.Wrap(err, "parsing to") + } + return MessageAttributes{ + subject: entity.Header.Get("subject"), + from: from, + to: to, + date: date.Unix(), + }, nil +} + +func parseTime(input string) (time.Time, error) { + for _, format := range []string{time.RFC1123, time.RFC1123Z} { + t, err := time.Parse(format, input) + if err == nil { + return t, nil + } + } + return time.Time{}, errors.New("Unrecognized time format") +} + +func parseAddresses(input string) ([]string, error) { + addresses, err := mail.ParseAddressList(input) + if err != nil { + return nil, err + } + result := []string{} + for _, address := range addresses { + result = append(result, address.Address) + } + return result, nil +} + +func parseAddress(input string) (string, error) { + address, err := mail.ParseAddress(input) + if err != nil { + return "", err + } + return address.Address, nil +} + +// BySubject implements sort.Interface based on the subject field. +type BySubject []MessageAttributes + +func (a BySubject) Len() int { return len(a) } +func (a BySubject) Less(i, j int) bool { return a[i].subject < a[j].subject } +func (a BySubject) Swap(i, j int) { a[i], a[j] = a[j], a[i] } diff --git a/test/transfer_setup_test.go b/test/transfer_setup_test.go new file mode 100644 index 00000000..b52bec46 --- /dev/null +++ b/test/transfer_setup_test.go @@ -0,0 +1,261 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge.Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package tests + +import ( + "bytes" + "fmt" + "net/textproto" + "os" + "path/filepath" + "strconv" + "time" + + "github.com/ProtonMail/proton-bridge/pkg/message" + "github.com/cucumber/godog" + "github.com/cucumber/godog/gherkin" + "github.com/emersion/go-imap" + "github.com/emersion/go-mbox" +) + +func TransferSetupFeatureContext(s *godog.Suite) { + s.Step(`^there are EML files$`, thereAreEMLFiles) + s.Step(`^there is EML file "([^"]*)"$`, thereIsEMLFile) + s.Step(`^there is MBOX file "([^"]*)" with messages$`, thereIsMBOXFileWithMessages) + s.Step(`^there is MBOX file "([^"]*)"$`, thereIsMBOXFile) + s.Step(`^there are IMAP mailboxes$`, thereAreIMAPMailboxes) + s.Step(`^there are IMAP messages$`, thereAreIMAPMessages) + s.Step(`^there is IMAP message in mailbox "([^"]*)" with seq (\d+), uid (\d+), time "([^"]*)" and subject "([^"]*)"$`, thereIsIMAPMessage) +} + +func thereAreEMLFiles(messages *gherkin.DataTable) error { + head := messages.Rows[0].Cells + for _, row := range messages.Rows[1:] { + fileName := "" + for n, cell := range row.Cells { + switch head[n].Value { + case "file": + fileName = cell.Value + case "from", "to", "subject", "time", "body": + default: + return fmt.Errorf("unexpected column name: %s", head[n].Value) + } + } + + body := getBodyFromDataRow(head, row) + if err := createFile(fileName, body); err != nil { + return err + } + } + return nil +} + +func thereIsEMLFile(fileName string, message *gherkin.DocString) error { + return createFile(fileName, message.Content) +} + +func thereIsMBOXFileWithMessages(fileName string, messages *gherkin.DataTable) error { + mboxBuffer := &bytes.Buffer{} + mboxWriter := mbox.NewWriter(mboxBuffer) + + head := messages.Rows[0].Cells + for _, row := range messages.Rows[1:] { + from := "" + for n, cell := range row.Cells { + switch head[n].Value { + case "from": + from = cell.Value + case "to", "subject", "time", "body": + default: + return fmt.Errorf("unexpected column name: %s", head[n].Value) + } + } + + body := getBodyFromDataRow(head, row) + + messageWriter, err := mboxWriter.CreateMessage(from, time.Now()) + if err != nil { + return err + } + _, err = messageWriter.Write([]byte(body)) + if err != nil { + return err + } + } + + return createFile(fileName, mboxBuffer.String()) +} + +func thereIsMBOXFile(fileName string, messages *gherkin.DocString) error { + return createFile(fileName, messages.Content) +} + +func thereAreIMAPMailboxes(mailboxes *gherkin.DataTable) error { + imapServer := ctx.GetTransferRemoteIMAPServer() + head := mailboxes.Rows[0].Cells + for _, row := range mailboxes.Rows[1:] { + mailboxName := "" + for n, cell := range row.Cells { + switch head[n].Value { + case "name": + mailboxName = cell.Value + default: + return fmt.Errorf("unexpected column name: %s", head[n].Value) + } + } + imapServer.AddMailbox(mailboxName) + } + return nil +} + +func thereAreIMAPMessages(messages *gherkin.DataTable) (err error) { + imapServer := ctx.GetTransferRemoteIMAPServer() + head := messages.Rows[0].Cells + for _, row := range messages.Rows[1:] { + mailboxName := "" + date := time.Now() + subject := "" + seqNum := 0 + uid := 0 + for n, cell := range row.Cells { + switch head[n].Value { + case "mailbox": + mailboxName = cell.Value + case "uid": + uid, err = strconv.Atoi(cell.Value) + if err != nil { + return internalError(err, "failed to parse uid") + } + case "seqnum": + seqNum, err = strconv.Atoi(cell.Value) + if err != nil { + return internalError(err, "failed to parse seqnum") + } + case "time": + date, err = time.Parse(timeFormat, cell.Value) + if err != nil { + return internalError(err, "failed to parse time") + } + case "subject": + subject = cell.Value + case "from", "to", "body": + default: + return fmt.Errorf("unexpected column name: %s", head[n].Value) + } + } + + body := getBodyFromDataRow(head, row) + imapMessage, err := getIMAPMessage(seqNum, uid, date, subject, body) + if err != nil { + return err + } + imapServer.AddMessage(mailboxName, imapMessage) + } + return nil +} + +func thereIsIMAPMessage(mailboxName string, seqNum, uid int, dateValue, subject string, message *gherkin.DocString) error { + imapServer := ctx.GetTransferRemoteIMAPServer() + + date, err := time.Parse(timeFormat, dateValue) + if err != nil { + return internalError(err, "failed to parse time") + } + + imapMessage, err := getIMAPMessage(seqNum, uid, date, subject, message.Content) + if err != nil { + return err + } + imapServer.AddMessage(mailboxName, imapMessage) + + return nil +} + +func getBodyFromDataRow(head []*gherkin.TableCell, row *gherkin.TableRow) string { + body := "hello" + headers := textproto.MIMEHeader{} + for n, cell := range row.Cells { + switch head[n].Value { + case "from": + headers.Set("from", cell.Value) + case "to": + headers.Set("to", cell.Value) + case "subject": + headers.Set("subject", cell.Value) + case "time": + date, err := time.Parse(timeFormat, cell.Value) + if err != nil { + panic(err) + } + headers.Set("date", date.Format(time.RFC1123)) + case "body": + body = cell.Value + } + } + + buffer := &bytes.Buffer{} + _ = message.WriteHeader(buffer, headers) + return buffer.String() + body + "\n\n" +} + +func getIMAPMessage(seqNum, uid int, date time.Time, subject, body string) (*imap.Message, error) { + reader := bytes.NewBufferString(body) + bodyStructure, err := message.NewBodyStructure(reader) + if err != nil { + return nil, internalError(err, "failed to parse body structure") + } + imapBodyStructure, err := bodyStructure.IMAPBodyStructure([]int{}) + if err != nil { + return nil, internalError(err, "failed to parse body structure") + } + bodySection, _ := imap.ParseBodySectionName("BODY[]") + + return &imap.Message{ + SeqNum: uint32(seqNum), + Uid: uint32(uid), + Size: uint32(len(body)), + Envelope: &imap.Envelope{ + Date: date, + Subject: subject, + }, + BodyStructure: imapBodyStructure, + Body: map[*imap.BodySectionName]imap.Literal{ + bodySection: bytes.NewBufferString(body), + }, + }, nil +} + +func createFile(fileName, body string) error { + root := ctx.GetTransferLocalRootForImport() + filePath := filepath.Join(root, fileName) + + dirPath := filepath.Dir(filePath) + err := os.MkdirAll(dirPath, os.ModePerm) + if err != nil { + return internalError(err, "failed to create dir") + } + + f, err := os.Create(filePath) + if err != nil { + return internalError(err, "failed to create file") + } + defer f.Close() //nolint + + _, err = f.WriteString(body) + return internalError(err, "failed to write to file") +} diff --git a/test/users_actions_test.go b/test/users_actions_test.go new file mode 100644 index 00000000..98fa229c --- /dev/null +++ b/test/users_actions_test.go @@ -0,0 +1,125 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge.Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package tests + +import ( + "github.com/cucumber/godog" +) + +func UsersActionsFeatureContext(s *godog.Suite) { + s.Step(`^"([^"]*)" logs in$`, userLogsIn) + s.Step(`^"([^"]*)" logs in with bad password$`, userLogsInWithBadPassword) + s.Step(`^"([^"]*)" logs out$`, userLogsOut) + s.Step(`^"([^"]*)" changes the address mode$`, userChangesTheAddressMode) + s.Step(`^user deletes "([^"]*)"$`, userDeletesUser) + s.Step(`^user deletes "([^"]*)" with cache$`, userDeletesUserWithCache) + s.Step(`^"([^"]*)" swaps address "([^"]*)" with address "([^"]*)"$`, swapsAddressWithAddress) +} + +func userLogsIn(bddUserID string) error { + account := ctx.GetTestAccount(bddUserID) + if account == nil { + return godog.ErrPending + } + ctx.SetLastError(ctx.LoginUser(account.Username(), account.Password(), account.MailboxPassword())) + return nil +} + +func userLogsInWithBadPassword(bddUserID string) error { + account := ctx.GetTestAccount(bddUserID) + if account == nil { + return godog.ErrPending + } + ctx.SetLastError(ctx.LoginUser(account.Username(), "you shall not pass!", "123")) + return nil +} + +func userLogsOut(bddUserID string) error { + account := ctx.GetTestAccount(bddUserID) + if account == nil { + return godog.ErrPending + } + ctx.SetLastError(ctx.LogoutUser(account.Username())) + return nil +} + +func userChangesTheAddressMode(bddUserID string) error { + account := ctx.GetTestAccount(bddUserID) + if account == nil { + return godog.ErrPending + } + user, err := ctx.GetUser(account.Username()) + if err != nil { + return internalError(err, "getting user %s", account.Username()) + } + if err := user.SwitchAddressMode(); err != nil { + return err + } + + ctx.EventuallySyncIsFinishedForUsername(account.Username()) + return nil +} + +func userDeletesUser(bddUserID string) error { + return deleteUser(bddUserID, false) +} + +func userDeletesUserWithCache(bddUserID string) error { + return deleteUser(bddUserID, true) +} + +func deleteUser(bddUserID string, cache bool) error { + account := ctx.GetTestAccount(bddUserID) + if account == nil { + return godog.ErrPending + } + user, err := ctx.GetUser(account.Username()) + if err != nil { + return internalError(err, "getting user %s", account.Username()) + } + ctx.SetLastError(ctx.GetUsers().DeleteUser(user.ID(), cache)) + return nil +} + +func swapsAddressWithAddress(bddUserID, bddAddressID1, bddAddressID2 string) error { + account := ctx.GetTestAccount(bddUserID) + if account == nil { + return godog.ErrPending + } + + address1ID := account.GetAddressID(bddAddressID1) + address2ID := account.GetAddressID(bddAddressID2) + addressIDs := make([]string, len(*account.Addresses())) + + var address1Index, address2Index int + for i, v := range *account.Addresses() { + if v.ID == address1ID { + address1Index = i + } + if v.ID == address2ID { + address2Index = i + } + addressIDs[i] = v.ID + } + + addressIDs[address1Index], addressIDs[address2Index] = addressIDs[address2Index], addressIDs[address1Index] + + ctx.ReorderAddresses(account.Username(), bddAddressID1, bddAddressID2) + + return ctx.GetPMAPIController().ReorderAddresses(account.User(), addressIDs) +} diff --git a/test/bridge_checks_test.go b/test/users_checks_test.go similarity index 77% rename from test/bridge_checks_test.go rename to test/users_checks_test.go index 980fecba..b816c1dc 100644 --- a/test/bridge_checks_test.go +++ b/test/users_checks_test.go @@ -24,8 +24,7 @@ import ( a "github.com/stretchr/testify/assert" ) -func BridgeChecksFeatureContext(s *godog.Suite) { - s.Step(`^bridge response is "([^"]*)"$`, bridgeResponseIs) +func UsersChecksFeatureContext(s *godog.Suite) { s.Step(`^"([^"]*)" has address mode in "([^"]*)" mode$`, userHasAddressModeInMode) s.Step(`^"([^"]*)" is disconnected$`, userIsDisconnected) s.Step(`^"([^"]*)" is connected$`, userIsConnected) @@ -39,27 +38,17 @@ func BridgeChecksFeatureContext(s *godog.Suite) { s.Step(`^"([^"]*)" has API auth$`, isAuthorized) } -func bridgeResponseIs(expectedResponse string) error { - err := ctx.GetLastBridgeError() - if expectedResponse == "OK" { - a.NoError(ctx.GetTestingT(), err) - } else { - a.EqualError(ctx.GetTestingT(), err, expectedResponse) - } - return ctx.GetTestingError() -} - func userHasAddressModeInMode(bddUserID, wantAddressMode string) error { account := ctx.GetTestAccount(bddUserID) if account == nil { return godog.ErrPending } - bridgeUser, err := ctx.GetUser(account.Username()) + user, err := ctx.GetUser(account.Username()) if err != nil { return internalError(err, "getting user %s", account.Username()) } addressMode := "split" - if bridgeUser.IsCombinedAddressMode() { + if user.IsCombinedAddressMode() { addressMode = "combined" } a.Equal(ctx.GetTestingT(), wantAddressMode, addressMode) @@ -71,12 +60,12 @@ func userIsDisconnected(bddUserID string) error { if account == nil { return godog.ErrPending } - bridgeUser, err := ctx.GetUser(account.Username()) + user, err := ctx.GetUser(account.Username()) if err != nil { return internalError(err, "getting user %s", account.Username()) } a.Eventually(ctx.GetTestingT(), func() bool { - return !bridgeUser.IsConnected() + return !user.IsConnected() }, 5*time.Second, 10*time.Millisecond) return ctx.GetTestingError() } @@ -87,13 +76,13 @@ func userIsConnected(bddUserID string) error { return godog.ErrPending } t := ctx.GetTestingT() - bridgeUser, err := ctx.GetUser(account.Username()) + user, err := ctx.GetUser(account.Username()) if err != nil { return internalError(err, "getting user %s", account.Username()) } - a.Eventually(ctx.GetTestingT(), bridgeUser.IsConnected, 5*time.Second, 10*time.Millisecond) - a.NotEmpty(t, bridgeUser.GetPrimaryAddress()) - a.NotEmpty(t, bridgeUser.GetStoreAddresses()) + a.Eventually(ctx.GetTestingT(), user.IsConnected, 5*time.Second, 10*time.Millisecond) + a.NotEmpty(t, user.GetPrimaryAddress()) + a.NotEmpty(t, user.GetStoreAddresses()) return ctx.GetTestingError() } @@ -122,11 +111,11 @@ func userHasLoadedStore(bddUserID string) error { if account == nil { return godog.ErrPending } - bridgeUser, err := ctx.GetUser(account.Username()) + user, err := ctx.GetUser(account.Username()) if err != nil { return internalError(err, "getting user %s", account.Username()) } - a.NotNil(ctx.GetTestingT(), bridgeUser.GetStore()) + a.NotNil(ctx.GetTestingT(), user.GetStore()) return ctx.GetTestingError() } @@ -135,11 +124,11 @@ func userDoesNotHaveLoadedStore(bddUserID string) error { if account == nil { return godog.ErrPending } - bridgeUser, err := ctx.GetUser(account.Username()) + user, err := ctx.GetUser(account.Username()) if err != nil { return internalError(err, "getting user %s", account.Username()) } - a.Nil(ctx.GetTestingT(), bridgeUser.GetStore()) + a.Nil(ctx.GetTestingT(), user.GetStore()) return ctx.GetTestingError() } @@ -173,28 +162,28 @@ func userDoesNotHaveRunningEventLoop(bddUserID string) error { return ctx.GetTestingError() } -func isAuthorized(accountName string) error { - account := ctx.GetTestAccount(accountName) +func isAuthorized(bddUserID string) error { + account := ctx.GetTestAccount(bddUserID) if account == nil { return godog.ErrPending } - bridgeUser, err := ctx.GetUser(account.Username()) + user, err := ctx.GetUser(account.Username()) if err != nil { return internalError(err, "getting user %s", account.Username()) } - a.Eventually(ctx.GetTestingT(), bridgeUser.IsAuthorized, 5*time.Second, 10*time.Millisecond) + a.Eventually(ctx.GetTestingT(), user.IsAuthorized, 5*time.Second, 10*time.Millisecond) return ctx.GetTestingError() } -func isNotAuthorized(accountName string) error { - account := ctx.GetTestAccount(accountName) +func isNotAuthorized(bddUserID string) error { + account := ctx.GetTestAccount(bddUserID) if account == nil { return godog.ErrPending } - bridgeUser, err := ctx.GetUser(account.Username()) + user, err := ctx.GetUser(account.Username()) if err != nil { return internalError(err, "getting user %s", account.Username()) } - a.Eventually(ctx.GetTestingT(), func() bool { return !bridgeUser.IsAuthorized() }, 5*time.Second, 10*time.Millisecond) + a.Eventually(ctx.GetTestingT(), func() bool { return !user.IsAuthorized() }, 5*time.Second, 10*time.Millisecond) return ctx.GetTestingError() } diff --git a/test/bridge_setup_test.go b/test/users_setup_test.go similarity index 89% rename from test/bridge_setup_test.go rename to test/users_setup_test.go index 261d27a8..495e574c 100644 --- a/test/bridge_setup_test.go +++ b/test/users_setup_test.go @@ -25,8 +25,7 @@ import ( a "github.com/stretchr/testify/assert" ) -func BridgeSetupFeatureContext(s *godog.Suite) { - s.Step(`^there is no internet connection$`, thereIsNoInternetConnection) +func UsersSetupFeatureContext(s *godog.Suite) { s.Step(`^there is user "([^"]*)"$`, thereIsUser) s.Step(`^there is connected user "([^"]*)"$`, thereIsConnectedUser) s.Step(`^there is disconnected user "([^"]*)"$`, thereIsDisconnectedUser) @@ -35,11 +34,6 @@ func BridgeSetupFeatureContext(s *godog.Suite) { s.Step(`^there is "([^"]*)" in "([^"]*)" address mode$`, thereIsUserWithAddressMode) } -func thereIsNoInternetConnection() error { - ctx.GetPMAPIController().TurnInternetConnectionOff() - return nil -} - func thereIsUser(bddUserID string) error { account := ctx.GetTestAccount(bddUserID) if account == nil { @@ -87,7 +81,11 @@ func thereIsDisconnectedUser(bddUserID string) error { // logout is also called and if we would do login at the same time, it // wouldn't work. 100 ms after event loop is stopped should be enough. a.Eventually(ctx.GetTestingT(), func() bool { - return !user.GetStore().TestGetEventLoop().IsRunning() + store := user.GetStore() + if store == nil { + return true + } + return !store.TestGetEventLoop().IsRunning() }, 1*time.Second, 10*time.Millisecond) time.Sleep(100 * time.Millisecond) return ctx.GetTestingError() @@ -120,20 +118,20 @@ func thereIsUserWithAddressMode(bddUserID, wantAddressMode string) error { if account == nil { return godog.ErrPending } - bridgeUser, err := ctx.GetUser(account.Username()) + user, err := ctx.GetUser(account.Username()) if err != nil { return internalError(err, "getting user %s", account.Username()) } addressMode := "split" - if bridgeUser.IsCombinedAddressMode() { + if user.IsCombinedAddressMode() { addressMode = "combined" } if wantAddressMode != addressMode { - err := bridgeUser.SwitchAddressMode() + err := user.SwitchAddressMode() if err != nil { return internalError(err, "switching mode") } } - ctx.EventuallySyncIsFinishedForUsername(bridgeUser.Username()) + ctx.EventuallySyncIsFinishedForUsername(user.Username()) return nil } From 7e5e3d3dd469d26b14a1cd325db2b0571636ad3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20=C5=A0koda?= Date: Tue, 23 Jun 2020 15:35:54 +0200 Subject: [PATCH 04/22] Import/Export GUI --- .gitlab-ci.yml | 12 +- Changelog.md | 2 + go.mod | 2 + go.sum | 42 ++ internal/bridge/bridge.go | 21 +- internal/bridge/release_notes.go | 2 +- internal/frontend/qml/GuiIE.qml | 4 +- .../frontend/qml/ImportExportUI/DateRange.qml | 7 +- .../qml/ImportExportUI/DateRangeFunctions.qml | 24 +- .../qml/ImportExportUI/DateRangeMenu.qml | 18 +- .../qml/ImportExportUI/DialogExport.qml | 34 +- .../qml/ImportExportUI/DialogImport.qml | 49 +-- .../qml/ImportExportUI/ExportStructure.qml | 22 +- .../qml/ImportExportUI/FolderRowButton.qml | 8 +- .../qml/ImportExportUI/ImportDelegate.qml | 48 +-- .../qml/ImportExportUI/ImportStructure.qml | 3 +- .../qml/ImportExportUI/InlineDateRange.qml | 6 +- .../qml/ImportExportUI/LabelIconList.qml | 39 +- .../qml/ImportExportUI/SelectFolderMenu.qml | 94 ++--- internal/frontend/qml/tst_GuiIE.qml | 369 ++++++++++++++++- internal/frontend/qt-ie/enums.go | 60 ++- internal/frontend/qt-ie/error_list.go | 85 ++-- internal/frontend/qt-ie/export.go | 18 +- internal/frontend/qt-ie/frontend.go | 369 +++++++++-------- internal/frontend/qt-ie/import.go | 46 +-- internal/frontend/qt-ie/mbox.go | 188 +++++++++ internal/frontend/qt-ie/transfer_rules.go | 377 ++++++++++++++++++ internal/frontend/qt-ie/ui.go | 18 +- internal/frontend/types/types.go | 1 + internal/importexport/credits.go | 4 +- internal/importexport/importexport.go | 54 ++- internal/importexport/release_notes.go | 2 +- internal/store/mocks/mocks.go | 3 +- internal/store/mocks/utils_mocks.go | 3 +- internal/transfer/mailbox.go | 5 + internal/transfer/progress.go | 10 +- internal/transfer/provider_eml.go | 9 +- internal/transfer/provider_imap_errors.go | 66 +++ internal/transfer/provider_imap_utils.go | 12 +- internal/transfer/provider_pmapi.go | 29 +- internal/transfer/provider_pmapi_source.go | 17 +- internal/transfer/rules.go | 94 ++++- internal/transfer/rules_test.go | 39 +- internal/transfer/transfer.go | 33 +- internal/users/mocks/mocks.go | 3 +- pkg/pmapi/bugs.go | 20 - pkg/pmapi/bugs_test.go | 83 ++-- pkg/pmapi/client_types.go | 2 +- pkg/pmapi/mocks/mocks.go | 17 +- test/fakeapi/reports.go | 12 +- 50 files changed, 1793 insertions(+), 692 deletions(-) create mode 100644 internal/frontend/qt-ie/mbox.go create mode 100644 internal/frontend/qt-ie/transfer_rules.go create mode 100644 internal/transfer/provider_imap_errors.go diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 00ba87a8..bd5c1c5d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -94,9 +94,9 @@ build-ie-linux: script: - make build-ie artifacts: - name: "bridge-linux-$CI_COMMIT_SHORT_SHA" + name: "ie-linux-$CI_COMMIT_SHORT_SHA" paths: - - bridge_*.tgz + - ie_*.tgz expire_in: 2 week build-darwin: @@ -145,9 +145,9 @@ build-ie-darwin: script: - make build-ie artifacts: - name: "bridge-darwin-$CI_COMMIT_SHORT_SHA" + name: "ie-darwin-$CI_COMMIT_SHORT_SHA" paths: - - bridge_*.tgz + - ie_*.tgz expire_in: 2 week build-windows: @@ -189,9 +189,9 @@ build-ie-windows: - go mod download - TARGET_OS=windows make build-ie artifacts: - name: "bridge-windows-$CI_COMMIT_SHORT_SHA" + name: "ie-windows-$CI_COMMIT_SHORT_SHA" paths: - - bridge_*.tgz + - ie_*.tgz expire_in: 2 week # Stage: MIRROR diff --git a/Changelog.md b/Changelog.md index 05861d1c..7e6bf64b 100644 --- a/Changelog.md +++ b/Changelog.md @@ -47,6 +47,8 @@ Changelog [format](http://keepachangelog.com/en/1.0.0/) * GODT-554 Detect and notify about "bad certificate" IMAP TLS error. * IMAP mailbox info update when new mailbox is created. * GODT-72 Use ISO-8859-1 encoding if charset is not specified and it isn't UTF-8. +* Structure for transfer rules in QML +* GODT-360 Detect charset embedded in html/xml. ### Changed * GODT-360 Detect charset embedded in html/xml. diff --git a/go.mod b/go.mod index c499d754..a4c40251 100644 --- a/go.mod +++ b/go.mod @@ -46,6 +46,7 @@ require ( github.com/golang/mock v1.4.4 github.com/google/go-cmp v0.5.1 github.com/google/uuid v1.1.1 + github.com/go-delve/delve v1.4.1 // indirect github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c // indirect github.com/hashicorp/go-multierror v1.1.0 github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 @@ -59,6 +60,7 @@ require ( github.com/nsf/jsondiff v0.0.0-20200515183724-f29ed568f4ce github.com/olekukonko/tablewriter v0.0.4 // indirect github.com/pkg/errors v0.9.1 + github.com/psampaz/go-mod-outdated v0.6.0 // indirect github.com/sirupsen/logrus v1.6.0 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/stretchr/testify v1.6.1 diff --git a/go.sum b/go.sum index 79d0f39d..8ea5870e 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,9 @@ github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/cosiner/argv v0.0.0-20170225145430-13bacc38a0a5/go.mod h1:p/NrK5tF6ICIly4qwEDsf6VDirFiWWz0FenfYBwJaKQ= +github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -81,6 +84,10 @@ github.com/getsentry/raven-go v0.2.0 h1:no+xWJRb5ZI7eE8TWgIq1jLulQiIoLG0IfYxv5JY github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= github.com/go-resty/resty/v2 v2.3.0 h1:JOOeAvjSlapTT92p8xiS19Zxev1neGikoHsXJeOq8So= github.com/go-resty/resty/v2 v2.3.0/go.mod h1:UpN9CgLZNsv4e9XG50UU8xdI0F43UQ4HmxLBDwaroHU= +github.com/go-delve/delve v1.4.1 h1:kZs0umEv+VKnK84kY9/ZXWrakdLTeRTyYjFdgLelZCQ= +github.com/go-delve/delve v1.4.1/go.mod h1:vmy6iObn7zg8FQ5KOCIe6TruMNsqpoZO8uMiRea+97k= +github.com/go-resty/resty/v2 v2.2.0 h1:vgZ1cdblp8Aw4jZj3ZsKh6yKAlMg3CHMrqFSFFd+jgY= +github.com/go-resty/resty/v2 v2.2.0/go.mod h1:nYW/8rxqQCmI3bPz9Fsmjbr2FBjGuR2Mzt6kDh3zZ7w= github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw= github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561 h1:aBzukfDxQlCTVS0NBUjI5YA3iVeaZ9Tb5PxNrrIP1xs= @@ -91,6 +98,11 @@ github.com/google/go-cmp v0.5.1 h1:JFrFEBb2xKufg6XkJsJr+WbKb4FQlURi5RUcBveYu9k= github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/golang/mock v1.4.3 h1:GV+pQPG/EUUbkh47niozDcADz6go/dUwhVzdUQHIVRw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-dap v0.2.0/go.mod h1:5q8aYQFnHOAZEMP+6vmq25HKYAEwE+LF5yh7JKrrhSQ= github.com/gopherjs/gopherjs v0.0.0-20190411002643-bd77b112433e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c h1:7lF+Vz0LqiRidnzC1Oq86fpX1q/iEv2KJdrCtttYjT4= github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= @@ -100,6 +112,10 @@ github.com/hashicorp/go-multierror v1.1.0 h1:B9UzwGQJehnUY1yNrnwREHc3fGbC2xefo8g github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= github.com/jameshoulahan/go-imap v0.0.0-20200728140727-d57327f48843 h1:suxlO4AC4E4bjueAsL0m+qp8kmkxRWMGj+5bBU/KJ8g= github.com/jameshoulahan/go-imap v0.0.0-20200728140727-d57327f48843/go.mod h1:yKASt+C3ZiDAiCSssxg9caIckWF/JG7ZQTO7GAmvicU= +github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/jaytaylor/html2text v0.0.0-20190408195923-01ec452cbe43/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 h1:g0fAGBisHaEQ0TRq1iBvemFRf+8AEWEmBESSiWB3Vsc= github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= @@ -121,10 +137,14 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= +github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381 h1:bqDmpDG49ZRnB5PcgP0RXtQvnMSgIF14M7CBd2shtXs= +github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= github.com/martinlindhe/base36 v1.0.0 h1:eYsumTah144C0A8P1T/AVSUk5ZoLnhfYFM3OGQxB52A= github.com/martinlindhe/base36 v1.0.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8= +github.com/mattn/go-colorable v0.0.0-20170327083344-ded68f7a9561/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGeM= github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= @@ -135,6 +155,8 @@ github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/Qd github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/miekg/dns v1.1.30 h1:Qww6FseFn8PRfw07jueqIXqodm0JKiiKuK0DeXSqfyo= github.com/miekg/dns v1.1.30/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= +github.com/miekg/dns v1.1.29 h1:xHBEhR+t5RzcFJjBLJlax2daXOrTYtr9z4WdKEfWFzg= +github.com/miekg/dns v1.1.29/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= github.com/myesui/uuid v1.0.0 h1:xCBmH4l5KuvLYc5L7AS7SZg9/jKdIFubM7OVoLqaQUI= github.com/myesui/uuid v1.0.0/go.mod h1:2CDfNgU0LR8mIdO8vdWd8i9gWWxLlcoIGGpSNgafq84= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= @@ -145,11 +167,16 @@ github.com/olekukonko/tablewriter v0.0.1 h1:b3iUnf1v+ppJiOfNX4yxxqfWKMQPZR5yoh8u github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= github.com/olekukonko/tablewriter v0.0.4 h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8= github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA= +github.com/peterh/liner v0.0.0-20170317030525-88609521dc4b/go.mod h1:xIteQHvHuaLYG9IFj6mSxM0fCKrs34IrEQUhOYuGPHc= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/psampaz/go-mod-outdated v0.6.0 h1:DXS6rdsz4rpezbPsckQflqrYSEBvsF5GAmUWP+UvnQo= +github.com/psampaz/go-mod-outdated v0.6.0/go.mod h1:r78NYWd1z+F9Zdsfy70svgXOz363B08BWnTyFSgEESs= +github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca h1:NugYot0LIVPxTvN8n+Kvkn6TrbMyxQiuvKdEwFdR9vI= @@ -163,6 +190,8 @@ github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= +github.com/spf13/cobra v0.0.0-20170417170307-b6cb39589372/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/pflag v0.0.0-20170417173400-9e4c21054fa1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -192,6 +221,12 @@ github.com/urfave/cli v1.22.4 h1:u7tSpNPPswAFymm8IehJhy4uJMlUuU/GmqSkvJ1InXA= github.com/urfave/cli v1.22.4/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0= go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= +github.com/urfave/cli v1.22.3 h1:FpNT6zq26xNpHZy08emi755QwzLPs6Pukqjlc7RfOMU= +github.com/urfave/cli v1.22.3/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +go.etcd.io/bbolt v1.3.3 h1:MUGmc65QhB3pIlaQ5bB4LwqSj6GIonVJXpZiaKNyaKk= +go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.starlark.net v0.0.0-20190702223751-32f345186213/go.mod h1:c1/X6cHgvdXj6pUlmWKMkuqRnW4K8x2vwt6JAaaircg= +golang.org/x/arch v0.0.0-20190927153633-4e8777c89be4/go.mod h1:flIaEI6LNU6xOCD5PaJvn9wGP0agmIOqjrtsKGRguv4= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -211,6 +246,7 @@ golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190419153524-e8e3143a4f4a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -226,8 +262,10 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190420181800-aa740d480789/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191127201027-ecd32218bd7f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191216052735-49a3e744a425 h1:VvQyQJN0tSuecqgcIxMWnnfG5kSmgy9KZR9sW3W5QeA= golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -237,8 +275,12 @@ gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8X gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/stretchr/testify.v1 v1.2.2 h1:yhQC6Uy5CqibAIlk1wlusa/MJ3iAN49/BsR/dCCKz3M= gopkg.in/stretchr/testify.v1 v1.2.2/go.mod h1:QI5V/q6UbPmuhtm10CaFZxED9NreB8PnFYN9JcR6TxU= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/internal/bridge/bridge.go b/internal/bridge/bridge.go index 3d753afd..0961107e 100644 --- a/internal/bridge/bridge.go +++ b/internal/bridge/bridge.go @@ -25,6 +25,7 @@ import ( "github.com/ProtonMail/proton-bridge/internal/metrics" "github.com/ProtonMail/proton-bridge/internal/preferences" "github.com/ProtonMail/proton-bridge/internal/users" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" "github.com/ProtonMail/proton-bridge/pkg/listener" logrus "github.com/sirupsen/logrus" @@ -130,15 +131,17 @@ func (b *Bridge) ReportBug(osType, osVersion, description, accountName, address, defer c.Logout() title := "[Bridge] Bug" - if err := c.ReportBugWithEmailClient( - osType, - osVersion, - title, - description, - accountName, - address, - emailClient, - ); err != nil { + report := pmapi.ReportReq{ + OS: osType, + OSVersion: osVersion, + Browser: emailClient, + Title: title, + Description: description, + Username: accountName, + Email: address, + } + + if err := c.Report(report); err != nil { log.Error("Reporting bug failed: ", err) return err } diff --git a/internal/bridge/release_notes.go b/internal/bridge/release_notes.go index c3f9a024..ab69ebed 100644 --- a/internal/bridge/release_notes.go +++ b/internal/bridge/release_notes.go @@ -15,7 +15,7 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -// Code generated by ./release-notes.sh at Wed 29 Jul 2020 07:07:28 AM CEST. DO NOT EDIT. +// Code generated by ./release-notes.sh at 'Fri 07 Aug 2020 06:34:27 AM CEST'. DO NOT EDIT. package bridge diff --git a/internal/frontend/qml/GuiIE.qml b/internal/frontend/qml/GuiIE.qml index 04c117c4..bac8918e 100644 --- a/internal/frontend/qml/GuiIE.qml +++ b/internal/frontend/qml/GuiIE.qml @@ -38,7 +38,7 @@ Item { property var allMonths : getMonthList(1,12) property var allDays : getDayList(1,31) - property var enums : JSON.parse('{"pathOK":1,"pathEmptyPath":2,"pathWrongPath":4,"pathNotADir":8,"pathWrongPermissions":16,"pathDirEmpty":32,"errUnknownError":0,"errEventAPILogout":1,"errUpdateAPI":2,"errUpdateJSON":3,"errUserAuth":4,"errQApplication":18,"errEmailExportFailed":6,"errEmailExportMissing":7,"errNothingToImport":8,"errEmailImportFailed":12,"errDraftImportFailed":13,"errDraftLabelFailed":14,"errEncryptMessageAttachment":15,"errEncryptMessage":16,"errNoInternetWhileImport":17,"errUnlockUser":5,"errSourceMessageNotSelected":19,"errCannotParseMail":5000,"errWrongLoginOrPassword":5001,"errWrongServerPathOrPort":5002,"errWrongAuthMethod":5003,"errIMAPFetchFailed":5004,"errLocalSourceLoadFailed":1000,"errPMLoadFailed":1001,"errRemoteSourceLoadFailed":1002,"errLoadAccountList":1005,"errExit":1006,"errRetry":1007,"errAsk":1008,"errImportFailed":1009,"errCreateLabelFailed":1010,"errCreateFolderFailed":1011,"errUpdateLabelFailed":1012,"errUpdateFolderFailed":1013,"errFillFolderName":1014,"errSelectFolderColor":1015,"errNoInternet":1016,"folderTypeSystem":"","folderTypeLabel":"label","folderTypeFolder":"folder","folderTypeExternal":"external","progressInit":"init","progressLooping":"looping","statusNoInternet":"noInternet","statusCheckingInternet":"internetCheck","statusNewVersionAvailable":"oldVersion","statusUpToDate":"upToDate","statusForceUpdate":"forceupdate"}') + property var enums : JSON.parse('{"pathOK":1,"pathEmptyPath":2,"pathWrongPath":4,"pathNotADir":8,"pathWrongPermissions":16,"pathDirEmpty":32,"errUnknownError":0,"errEventAPILogout":1,"errUpdateAPI":2,"errUpdateJSON":3,"errUserAuth":4,"errQApplication":18,"errEmailExportFailed":6,"errEmailExportMissing":7,"errNothingToImport":8,"errEmailImportFailed":12,"errDraftImportFailed":13,"errDraftLabelFailed":14,"errEncryptMessageAttachment":15,"errEncryptMessage":16,"errNoInternetWhileImport":17,"errUnlockUser":5,"errSourceMessageNotSelected":19,"errCannotParseMail":5000,"errWrongLoginOrPassword":5001,"errWrongServerPathOrPort":5002,"errWrongAuthMethod":5003,"errIMAPFetchFailed":5004,"errLocalSourceLoadFailed":1000,"errPMLoadFailed":1001,"errRemoteSourceLoadFailed":1002,"errLoadAccountList":1005,"errExit":1006,"errRetry":1007,"errAsk":1008,"errImportFailed":1009,"errCreateLabelFailed":1010,"errCreateFolderFailed":1011,"errUpdateLabelFailed":1012,"errUpdateFolderFailed":1013,"errFillFolderName":1014,"errSelectFolderColor":1015,"errNoInternet":1016,"folderTypeSystem":"system","folderTypeLabel":"label","folderTypeFolder":"folder","folderTypeExternal":"external","progressInit":"init","progressLooping":"looping","statusNoInternet":"noInternet","statusCheckingInternet":"internetCheck","statusNewVersionAvailable":"oldVersion","statusUpToDate":"upToDate","statusForceUpdate":"forceupdate"}') IEStyle{} @@ -396,7 +396,7 @@ Item { onTriggered : go.runCheckVersion(false) } - property string areYouSureYouWantToQuit : qsTr("Tool does not finished all the jobs. Do you really want to quit?") + property string areYouSureYouWantToQuit : qsTr("There are incomplete processes - some items are not yet transferred. Do you really want to stop and quit?") // On start Component.onCompleted : { // set spell messages diff --git a/internal/frontend/qml/ImportExportUI/DateRange.qml b/internal/frontend/qml/ImportExportUI/DateRange.qml index 26ebc016..d1decff5 100644 --- a/internal/frontend/qml/ImportExportUI/DateRange.qml +++ b/internal/frontend/qml/ImportExportUI/DateRange.qml @@ -25,14 +25,15 @@ import ImportExportUI 1.0 Column { id: dateRange - property var structure : structureExternal - property string sourceID : structureExternal.getID ( -1 ) + property var structure : transferRules + property string sourceID : "-1" property alias allDates : allDatesBox.checked property alias inputDateFrom : inputDateFrom property alias inputDateTo : inputDateTo - function setRange() {common.setRange()} + function getRange() {common.getRange()} + function setRangeFromTo(from, to) {common.setRangeFromTo(from, to)} function applyRange() {common.applyRange()} property var dropDownStyle : Style.dropDownLight diff --git a/internal/frontend/qml/ImportExportUI/DateRangeFunctions.qml b/internal/frontend/qml/ImportExportUI/DateRangeFunctions.qml index 8dceb497..aed413b9 100644 --- a/internal/frontend/qml/ImportExportUI/DateRangeFunctions.qml +++ b/internal/frontend/qml/ImportExportUI/DateRangeFunctions.qml @@ -34,7 +34,7 @@ Item { property alias inputDateFrom : inputDateFrom property alias inputDateTo : inputDateTo - function setRange() {common.setRange()} + function getRange() {common.getRange()} function applyRange() {common.applyRange()} */ @@ -43,11 +43,7 @@ Item { inputDateTo.setDate((new Date()).getTime()) } - function setRange(){ // unix time in seconds - var folderFrom = dateRange.structure.getFrom(dateRange.sourceID) - if (folderFrom===undefined) folderFrom = 0 - var folderTo = dateRange.structure.getTo(dateRange.sourceID) - if (folderTo===undefined) folderTo = 0 + function setRangeFromTo(folderFrom, folderTo){ // unix time in seconds if ( folderFrom == 0 && folderTo ==0 ) { dateRange.allDates = true } else { @@ -57,6 +53,15 @@ Item { } } + function getRange(){ // unix time in seconds + //console.log(" ==== GET RANGE === ") + //console.trace() + var folderFrom = dateRange.structure.globalFromDate + var folderTo = dateRange.structure.globalToDate + + root.setRangeFromTo(folderFrom, folderTo) + } + function applyRange(){ // unix time is seconds if (dateRange.allDates) structure.setFromToDate(dateRange.sourceID, 0, 0) else { @@ -67,15 +72,10 @@ Item { } } - Connections { - target: dateRange - onStructureChanged: setRange() - } - Component.onCompleted: { inputDateFrom.updateRange(gui.netBday) inputDateTo.updateRange(new Date()) - setRange() + //getRange() } } diff --git a/internal/frontend/qml/ImportExportUI/DateRangeMenu.qml b/internal/frontend/qml/ImportExportUI/DateRangeMenu.qml index 4ef6853b..4382e8ab 100644 --- a/internal/frontend/qml/ImportExportUI/DateRangeMenu.qml +++ b/internal/frontend/qml/ImportExportUI/DateRangeMenu.qml @@ -31,8 +31,10 @@ Rectangle { property real padding : Style.dialog.spacing property bool down : popup.visible - property var structure : structureExternal - property string sourceID : structureExternal.getID(-1) + property var structure : transferRules + property string sourceID : "" + property int sourceFromDate : 0 + property int sourceToDate : 0 color: Style.transparent @@ -145,7 +147,17 @@ Rectangle { } } - onAboutToShow : dateRangeInput.setRange() + onAboutToShow : updateRange() onAboutToHide : dateRangeInput.applyRange() } + + function updateRange() { + dateRangeInput.setRangeFromTo(root.sourceFromDate, root.sourceToDate) + } + + Connections { + target:root + onSourceFromDateChanged: root.updateRange() + onSourceToDateChanged: root.updateRange() + } } diff --git a/internal/frontend/qml/ImportExportUI/DialogExport.qml b/internal/frontend/qml/ImportExportUI/DialogExport.qml index 2d44fb74..6ffd196d 100644 --- a/internal/frontend/qml/ImportExportUI/DialogExport.qml +++ b/internal/frontend/qml/ImportExportUI/DialogExport.qml @@ -91,8 +91,6 @@ Dialog { DateRange{ id: dateRangeInput - structure: structurePM - sourceID: structurePM.getID(-1) } OutputFormat { @@ -142,7 +140,7 @@ Dialog { id: buttonNext fa_icon: Style.fa.check text: qsTr("Export","todo") - enabled: structurePM != 0 + enabled: transferRules != 0 color_main: Style.dialog.background color_minor: enabled ? Style.dialog.textBlue : Style.main.textDisabled isOpaque: true @@ -168,13 +166,17 @@ Dialog { spacing: Style.main.rightMargin AccessibleText { id: statusLabel - text : qsTr("Exporting to:") + text : qsTr("Status:") font.pointSize: Style.main.iconSize * Style.pt color : Style.main.text } AccessibleText { anchors.baseline: statusLabel.baseline - text : go.progressDescription == gui.enums.progressInit ? outputPathInput.path : go.progressDescription + text : { + if (progressbarExport.isFinished) return qsTr("finished") + if (go.progressDescription == "") return qsTr("exporting") + return go.progressDescription + } elide: Text.ElideMiddle width: progressbarExport.width - parent.spacing - statusLabel.width font.pointSize: Style.dialog.textSize * Style.pt @@ -310,15 +312,17 @@ Dialog { function check_inputs() { if (currentIndex == 1) { // at least one email to export - if (structurePM.rowCount() == 0){ + if (transferRules.rowCount() == 0){ errorPopup.show(qsTr("No emails found to export. Please try another address.", "todo")) return false } // at least one source selected - if (!structurePM.atLeastOneSelected) { - errorPopup.show(qsTr("Please select at least one item to export.", "todo")) - return false - } + /* + if (!transferRules.atLeastOneSelected) { + errorPopup.show(qsTr("Please select at least one item to export.", "todo")) + return false + } + */ // check path var folderCheck = go.checkPathStatus(outputPathInput.path) switch (folderCheck) { @@ -364,7 +368,6 @@ Dialog { errorPopup.buttonYes.visible = true errorPopup.buttonNo.visible = true errorPopup.buttonOkay.visible = false - errorPopup.checkbox.text = root.msgClearUnfished errorPopup.show ("Are you sure you want to cancel this export?") } @@ -374,10 +377,7 @@ Dialog { case 0 : case 1 : root.hide(); break; case 2 : // progress bar - go.cancelProcess ( - errorPopup.checkbox.text == root.msgClearUnfished && - errorPopup.checkbox.checked - ); + go.cancelProcess(); // no break default: root.clear_status() @@ -395,7 +395,7 @@ Dialog { root.hide() break case 0: // loading structure - dateRangeInput.setRange() + dateRangeInput.getRange() //no break default: incrementCurrentIndex() @@ -426,7 +426,7 @@ Dialog { switch (currentIndex) { case 0: go.loadStructureForExport(root.address) - sourceFoldersInput.hasItems = (structurePM.rowCount() > 0) + sourceFoldersInput.hasItems = (transferRules.rowCount() > 0) break case 2: dateRangeInput.applyRange() diff --git a/internal/frontend/qml/ImportExportUI/DialogImport.qml b/internal/frontend/qml/ImportExportUI/DialogImport.qml index 380e3fd4..c4fd4c1b 100644 --- a/internal/frontend/qml/ImportExportUI/DialogImport.qml +++ b/internal/frontend/qml/ImportExportUI/DialogImport.qml @@ -327,6 +327,7 @@ Dialog { iconText: Style.fa.refresh textColor: Style.main.textBlue onClicked: { + go.resetSource() root.decrementCurrentIndex() timer.start() } @@ -408,20 +409,13 @@ Dialog { spacing: Style.main.rightMargin AccessibleText { id: statusLabel - text : qsTr("Importing from:") + text : qsTr("Status:") font.pointSize: Style.main.iconSize * Style.pt color : Style.main.text } AccessibleText { anchors.baseline: statusLabel.baseline - text : { - var sourceFolder = root.isFromFile ? root.inputPath : inputEmail.text - if (go.progressDescription != gui.enums.progressInit && go.progress!=0) { - sourceFolder += "/" - sourceFolder += go.progressDescription - } - return sourceFolder - } + text : go.progressDescription == "" ? qsTr("importing") : go.progressDescription elide: Text.ElideMiddle width: progressbarImport.width - parent.spacing - statusLabel.width font.pointSize: Style.dialog.textSize * Style.pt @@ -582,9 +576,9 @@ Dialog { spacing : Style.dialog.heightSeparator Text { - text: Style.fa.check_circle + " " + qsTr("Import completed successfully") + text: go.progressDescription!="" ? qsTr("Import failed: %1").arg(go.progressDescription) : Style.fa.check_circle + " " + qsTr("Import completed successfully") anchors.horizontalCenter: parent.horizontalCenter - color: Style.main.textGreen + color: go.progressDescription!="" ? Style.main.textRed : Style.main.textGreen font.bold : true font.family: Style.fontawesome.name } @@ -605,11 +599,7 @@ Dialog { text : qsTr("View errors") color_main : Style.dialog.textBlue onClicked : { - if (go.importLogFileName=="") { - console.log("onViewErrors: missing import log file name") - return - } - go.loadImportReports(go.importLogFileName) + go.loadImportReports() reportList.show() } } @@ -619,10 +609,6 @@ Dialog { text : qsTr("Report files") color_main : Style.dialog.textBlue onClicked : { - if (go.importLogFileName=="") { - console.log("onReportError: missing import log file name") - return - } root.ask_send_report() } } @@ -755,7 +741,6 @@ Dialog { } function clear() { - go.resetSource() root.inputPath = "" clear_status() inputEmail.clear() @@ -781,7 +766,7 @@ Dialog { onClickedYes : { if (errorPopup.msgID == "ask_send_report") { errorPopup.hide() - root.report_sent(go.sendImportReport(root.address,go.importLogFileName)) + root.report_sent(go.sendImportReport(root.address)) return } root.cancel() @@ -857,10 +842,13 @@ Dialog { } break case 3: // import insturctions - if (!structureExternal.hasTarget()) { - errorPopup.show(qsTr("Nothing selected for import.")) - return false - } + /* + console.log(" ====== TODO ======== ") + if (!structureExternal.hasTarget()) { + errorPopup.show(qsTr("Nothing selected for import.")) + return false + } + */ break case 4: // import status } @@ -880,7 +868,7 @@ Dialog { root.hide() break case DialogImport.Page.Progress: - go.cancelProcess(false) + go.cancelProcess() root.currentIndex=3 root.clear_status() globalLabels.reset() @@ -905,7 +893,7 @@ Dialog { globalLabels.labelName, globalLabels.labelColor, true, - structureExternal.getID(-1) + "-1" ) if (!isOK) return } @@ -919,7 +907,8 @@ Dialog { case DialogImport.Page.LoadingStructure: globalLabels.reset() - importInstructions.hasItems = (structureExternal.rowCount() > 0) + // TODO_: importInstructions.hasItems = (structureExternal.rowCount() > 0) + importInstructions.hasItems = true case DialogImport.Page.ImapSource: default: incrementCurrentIndex() @@ -1008,7 +997,7 @@ Dialog { case DialogImport.Page.SelectSourceType: case DialogImport.Page.ImapSource: case DialogImport.Page.SourceToTarget: - globalDateRange.setRange() + globalDateRange.getRange() break case DialogImport.Page.LoadingStructure: go.setupAndLoadForImport( diff --git a/internal/frontend/qml/ImportExportUI/ExportStructure.qml b/internal/frontend/qml/ImportExportUI/ExportStructure.qml index 867f3ab5..ebac48af 100644 --- a/internal/frontend/qml/ImportExportUI/ExportStructure.qml +++ b/internal/frontend/qml/ImportExportUI/ExportStructure.qml @@ -92,7 +92,7 @@ Rectangle { clip : true orientation : ListView.Vertical boundsBehavior : Flickable.StopAtBounds - model : structurePM + model : transferRules cacheBuffer : 10000 anchors { @@ -125,27 +125,25 @@ Rectangle { } delegate: FolderRowButton { + property variant modelData: model width : root.width - 5*root.border.width - type : folderType - color : folderColor - title : folderName - isSelected : isFolderSelected + type : modelData.type + folderIconColor : modelData.iconColor + title : modelData.name + isSelected : modelData.isActive onClicked : { //console.log("Clicked", folderId, isSelected) - structurePM.setFolderSelection(folderId,!isSelected) + transferRules.setIsRuleActive(modelData.mboxID,!model.isActive) } } - section.property: "folderType" + section.property: "type" section.delegate: FolderRowButton { isSection : true width : root.width - 5*root.border.width title : gui.folderTypeTitle(section) - isSelected : { - //console.log("section selected changed: ", section) - return section == gui.enums.folderTypeLabel ? structurePM.selectedLabels : structurePM.selectedFolders - } - onClicked : structurePM.selectType(section,!isSelected) + isSelected : section == gui.enums.folderTypeLabel ? transferRules.isLabelGroupSelected : transferRules.isFolderGroupSelected + onClicked : transferRules.setIsGroupActive(section,!isSelected) } } } diff --git a/internal/frontend/qml/ImportExportUI/FolderRowButton.qml b/internal/frontend/qml/ImportExportUI/FolderRowButton.qml index 43e85554..a7c79db1 100644 --- a/internal/frontend/qml/ImportExportUI/FolderRowButton.qml +++ b/internal/frontend/qml/ImportExportUI/FolderRowButton.qml @@ -26,9 +26,9 @@ AccessibleButton { property bool isSection : false property bool isSelected : false - property string title : "N/A" - property string type : "" - property color color : "black" + property string title : "N/A" + property string type : "" + property string folderIconColor : Style.main.textBlue height : Style.exporting.rowHeight padding : 0.0 @@ -72,7 +72,7 @@ AccessibleButton { left : checkbox.left leftMargin : Style.dialog.fontSize + Style.exporting.leftMargin } - color : root.type==gui.enums.folderTypeSystem ? Style.main.textBlue : root.color + color : root.type=="" ? Style.main.textBlue : root.folderIconColor font { family : Style.fontawesome.name pointSize : Style.dialog.fontSize * Style.pt diff --git a/internal/frontend/qml/ImportExportUI/ImportDelegate.qml b/internal/frontend/qml/ImportExportUI/ImportDelegate.qml index ebb72d35..aa916186 100644 --- a/internal/frontend/qml/ImportExportUI/ImportDelegate.qml +++ b/internal/frontend/qml/ImportExportUI/ImportDelegate.qml @@ -39,7 +39,7 @@ Rectangle { } property real iconWidth : nameWidth*0.3 - property bool isSourceSelected: targetFolderID!="" + property bool isSourceSelected: isActive property string lastTargetFolder: "6" // Archive property string lastTargetLabels: "" // no flag by default @@ -71,7 +71,7 @@ Rectangle { Text { id: folderIcon - text : gui.folderIcon(folderName, gui.enums.folderTypeFolder) + text : gui.folderIcon(name, gui.enums.folderTypeFolder) anchors.verticalCenter : parent.verticalCenter color: root.isSourceSelected ? Style.main.text : Style.main.textDisabled font { @@ -81,7 +81,7 @@ Rectangle { } Text { - text : folderName + text : name width: nameWidth elide: Text.ElideRight anchors.verticalCenter : parent.verticalCenter @@ -102,24 +102,27 @@ Rectangle { SelectFolderMenu { id: selectFolder - sourceID: folderId - selectedIDs: targetFolderID + sourceID: mboxID + targets: transferRules.targetFolders(mboxID) width: nameWidth anchors.verticalCenter : parent.verticalCenter + enabled: root.isSourceSelected onDoNotImport: root.toggleImport() onImportToFolder: root.importToFolder(newTargetID) } SelectLabelsMenu { - sourceID: folderId - selectedIDs: targetLabelIDs + sourceID: mboxID + targets: transferRules.targetLabels(mboxID) width: nameWidth anchors.verticalCenter : parent.verticalCenter enabled: root.isSourceSelected + onAddTargetLabel: { transferRules.addTargetID(sourceID, newTargetID) } + onRemoveTargetLabel: { transferRules.removeTargetID(sourceID, newTargetID) } } LabelIconList { - selectedIDs: targetLabelIDs + colorList: labelColors=="" ? [] : labelColors.split(";") width: iconWidth anchors.verticalCenter : parent.verticalCenter enabled: root.isSourceSelected @@ -127,38 +130,23 @@ Rectangle { DateRangeMenu { id: dateRangeMenu - sourceID: folderId + sourceID: mboxID + sourceFromDate: fromDate + sourceToDate: toDate enabled: root.isSourceSelected anchors.verticalCenter : parent.verticalCenter + + Component.onCompleted : dateRangeMenu.updateRange() } } function importToFolder(newTargetID) { - if (root.isSourceSelected) { - structureExternal.setTargetFolderID(folderId,newTargetID) - } else { - lastTargetFolder = newTargetID - toggleImport() - } + transferRules.addTargetID(mboxID,newTargetID) } function toggleImport() { - if (root.isSourceSelected) { - lastTargetFolder = targetFolderID - lastTargetLabels = targetLabelIDs - structureExternal.setTargetFolderID(folderId,"") - return Qt.Unchecked - } else { - structureExternal.setTargetFolderID(folderId,lastTargetFolder) - var labelsSplit = lastTargetLabels.split(";") - for (var labelIndex in labelsSplit) { - var labelID = labelsSplit[labelIndex] - structureExternal.addTargetLabelID(folderId,labelID) - } - return Qt.Checked - } + transferRules.setIsRuleActive(mboxID, !root.isSourceSelected) } - } diff --git a/internal/frontend/qml/ImportExportUI/ImportStructure.qml b/internal/frontend/qml/ImportExportUI/ImportStructure.qml index da94e46f..17956039 100644 --- a/internal/frontend/qml/ImportExportUI/ImportStructure.qml +++ b/internal/frontend/qml/ImportExportUI/ImportStructure.qml @@ -50,7 +50,6 @@ Rectangle { verticalAlignment: Text.AlignVCenter text: qsTr("No emails found for this source.","todo") } - } anchors { @@ -70,7 +69,7 @@ Rectangle { clip : true orientation : ListView.Vertical boundsBehavior : Flickable.StopAtBounds - model : structureExternal + model : transferRules cacheBuffer : 10000 delegate : ImportDelegate { width: root.width diff --git a/internal/frontend/qml/ImportExportUI/InlineDateRange.qml b/internal/frontend/qml/ImportExportUI/InlineDateRange.qml index 017b0eb4..8518048e 100644 --- a/internal/frontend/qml/ImportExportUI/InlineDateRange.qml +++ b/internal/frontend/qml/ImportExportUI/InlineDateRange.qml @@ -25,8 +25,8 @@ import ImportExportUI 1.0 Row { id: dateRange - property var structure : structureExternal - property string sourceID : structureExternal.getID ( -1 ) + property var structure : transferRules + property string sourceID : "-1" property alias allDates : allDatesBox.checked property alias inputDateFrom : inputDateFrom @@ -34,7 +34,7 @@ Row { property alias labelWidth: label.width - function setRange() {common.setRange()} + function getRange() {common.getRange()} function applyRange() {common.applyRange()} DateRangeFunctions {id:common} diff --git a/internal/frontend/qml/ImportExportUI/LabelIconList.qml b/internal/frontend/qml/ImportExportUI/LabelIconList.qml index bf6ab759..90c38858 100644 --- a/internal/frontend/qml/ImportExportUI/LabelIconList.qml +++ b/internal/frontend/qml/ImportExportUI/LabelIconList.qml @@ -26,42 +26,16 @@ Rectangle { id: root width: Style.main.fontSize * 2 height: metrics.height - property string selectedIDs : "" + property var colorList color: "transparent" - - DelegateModel { id: selectedLabels - filterOnGroup: "selected" - groups: DelegateModelGroup { - id: selected - name: "selected" - includeByDefault: true - } - model : structurePM + model : colorList delegate : Text { text : metrics.text font : metrics.font - color : folderColor===undefined ? "#000": folderColor - } - } - - function updateFilter() { - var selected = root.selectedIDs.split(";") - var rowCount = selectedLabels.items.count - //console.log(" log ::", root.selectedIDs, rowCount, selectedLabels.model) - // filter - for (var iItem = 0; iItem < rowCount; iItem++) { - var entry = selectedLabels.items.get(iItem); - //console.log(" log filter ", iItem, rowCount, entry.model.folderId, entry.model.folderType, selected[iSel], entry.inSelected ) - for (var iSel in selected) { - entry.inSelected = ( - entry.model.folderType == gui.enums.folderTypeLabel && - entry.model.folderId == selected[iSel] - ) - if (entry.inSelected) break // found match, skip rest - } + color : modelData } } @@ -77,7 +51,7 @@ Rectangle { Row { anchors.left : root.left spacing : { - var n = Math.max(2,selectedLabels.count) + var n = Math.max(2,root.colorList.length) var tagWidth = Math.max(1.0,metrics.width) var space = Math.min(1*Style.px, (root.width - n*tagWidth)/(n-1)) // not more than 1px space = Math.max(space,-tagWidth) // not less than tag width @@ -88,9 +62,4 @@ Rectangle { model: selectedLabels } } - - Component.onCompleted: root.updateFilter() - onSelectedIDsChanged: root.updateFilter() - Connections { target: structurePM; onDataChanged:root.updateFilter() } } - diff --git a/internal/frontend/qml/ImportExportUI/SelectFolderMenu.qml b/internal/frontend/qml/ImportExportUI/SelectFolderMenu.qml index 5476aef4..2dc3b20d 100644 --- a/internal/frontend/qml/ImportExportUI/SelectFolderMenu.qml +++ b/internal/frontend/qml/ImportExportUI/SelectFolderMenu.qml @@ -26,19 +26,19 @@ ComboBox { //fixme rounded height: Style.main.fontSize*2 //fixme property string folderType: gui.enums.folderTypeFolder - property string selectedIDs property string sourceID + property var targets property bool isFolderType: root.folderType == gui.enums.folderTypeFolder - property bool hasTarget: root.selectedIDs != "" property bool below: true signal doNotImport() signal importToFolder(string newTargetID) + signal addTargetLabel(string newTargetID) + signal removeTargetLabel(string newTargetID) leftPadding: Style.dialog.spacing onDownChanged : { - if (root.down) view.model.updateFilter() root.below = popup.y>0 } @@ -58,30 +58,22 @@ ComboBox { } displayText: { - //console.trace() - //console.log("updatebox", view.currentIndex, root.hasTarget, root.selectedIDs, root.sourceID, root.folderType) - if (!root.hasTarget) { - if (root.isFolderType) return qsTr("Do not import") - return qsTr("No labels selected") - } - if (!root.isFolderType) return Style.fa.tags + " " + qsTr("Add/Remove labels") + console.log("Target Menu current", view.currentItem, view.currentIndex) + if (view.currentIndex >= 0) { + if (!root.isFolderType) return Style.fa.tags + " " + qsTr("Add/Remove labels") - // We know here that it has a target and this is folder dropdown so we must find the first folder - var selSplit = root.selectedIDs.split(";") - for (var selIndex in selSplit) { - var selectedID = selSplit[selIndex] - var selectedType = structurePM.getType(selectedID) - if (selectedType == gui.enums.folderTypeLabel) continue; // skip type::labele - var selectedName = structurePM.getName(selectedID) - if (selectedName == "") continue; // empty name seems like wrong ID - var icon = gui.folderIcon(selectedName, selectedType) - if (selectedType == gui.enums.folderTypeSystem) { - return icon + " " + selectedName + var tgtName = view.currentItem.folderName + var tgtIcon = view.currentItem.folderIcon + var tgtColor = view.currentItem.folderColor + + if (tgtIcon != Style.fa.folder_open) { + return tgtIcon + " " + tgtName } - var iconColor = structurePM.getColor(selectedID) - return ''+ icon + " " + selectedName + + return ''+ tgtIcon + " " + tgtName } - return "" + if (root.isFolderType) return qsTr("No folder selected") + return qsTr("No labels selected") } @@ -116,7 +108,7 @@ ComboBox { color: root.enabled && !root.down ? Style.main.textBlue : root.contentItem.color } - // Popup objects + // Popup row delegate: Rectangle { id: thisDelegate @@ -127,22 +119,15 @@ ComboBox { color: isHovered ? root.popup.hoverColor : root.popup.backColor - - property bool isSelected : { - var selected = root.selectedIDs.split(";") - for (var iSel in selected) { - var sel = selected[iSel] - if (folderId == sel){ - return true - } - } - return false - } + property bool isSelected : isActive + property string folderName: name + property string folderIcon: gui.folderIcon(name,type) + property string folderColor: (type == gui.enums.folderTypeLabel || type == gui.enums.folderTypeFolder) ? iconColor : root.popup.textColor Text { id: targetIcon - text: gui.folderIcon(folderName,folderType) - color : folderType != gui.enums.folderTypeSystem ? folderColor : root.popup.textColor + text: thisDelegate.folderIcon + color : thisDelegate.folderColor anchors { verticalCenter: parent.verticalCenter left: parent.left @@ -157,6 +142,7 @@ ComboBox { Text { id: targetName + anchors { verticalCenter: parent.verticalCenter left: targetIcon.right @@ -165,7 +151,7 @@ ComboBox { rightMargin: Style.dialog.spacing } - text: folderName + text: thisDelegate.folderName color : root.popup.textColor elide: Text.ElideRight @@ -209,16 +195,15 @@ ComboBox { onClicked: { //console.log(" click delegate") if (root.isFolderType) { // don't update if selected - if (!thisDelegate.isSelected) { - root.importToFolder(folderId) - } root.popup.close() - } - if (root.folderType==gui.enums.folderTypeLabel) { - if (thisDelegate.isSelected) { - structureExternal.removeTargetLabelID(sourceID,folderId) + if (!isActive) { + root.importToFolder(mboxID) + } + } else { + if (isActive) { + root.removeTargetLabel(mboxID) } else { - structureExternal.addTargetLabelID(sourceID,folderId) + root.addTargetLabel(mboxID) } } } @@ -295,14 +280,10 @@ ComboBox { clip : true anchors.fill : parent + model : root.targets + delegate : root.delegate - section.property : "sectionName" - section.delegate : Text{text: sectionName} - - model : FilterStructure { - filterOnGroup : root.folderType - delegate : root.delegate - } + currentIndex: view.model.selectedIndex } } @@ -338,10 +319,7 @@ ComboBox { onClicked : { //console.log("click", addButton.text) - var newName = "" - if ( typeof folderName !== 'undefined' && !structurePM.hasFolderWithName (folderName) ) { - newName = folderName - } + var newName = name winMain.popupFolderEdit.show(newName, "", "", root.folderType, sourceID) root.popup.close() } diff --git a/internal/frontend/qml/tst_GuiIE.qml b/internal/frontend/qml/tst_GuiIE.qml index 625edda0..c96f4ce1 100644 --- a/internal/frontend/qml/tst_GuiIE.qml +++ b/internal/frontend/qml/tst_GuiIE.qml @@ -210,7 +210,7 @@ Window { Component.onCompleted : { - testgui.winMain.x = 150 + testgui.winMain.x = 350 testgui.winMain.y = 100 } @@ -230,7 +230,7 @@ Window { } ListModel{ - id: structureExternal + id: structureExternalOFF property var globalOptions: JSON.parse('{ "folderId" : "global--uniq" , "folderName" : "" , "folderColor" : "" , "folderType" : "" , "folderEntries" : 0, "fromDate": 0, "toDate": 0, "isFolderSelected" : false , "targetFolderID": "14" , "targetLabelIDs": ";20;29" }') @@ -265,7 +265,7 @@ Window { } ListModel{ - id: structurePM + id: structurePMOFF // group selectors property bool selectedLabels : false @@ -328,6 +328,7 @@ Window { } } + function setTypeSelected (model, folderType , toSelect ) { console.log(" select type ", folderType, toSelect) for (var i= -1; i= len(s.Details) { + if index.Row() >= len(e.records) { return core.NewQVariant() } - var p = s.Details[index.Row()] + var r = e.records[index.Row()] switch role { case MailSubject: - return qtcommon.NewQVariantString(p.MailSubject) + return qtcommon.NewQVariantString(r.Subject) case MailDate: - return qtcommon.NewQVariantString(p.MailDate) + return qtcommon.NewQVariantString(r.Time.String()) case MailFrom: - return qtcommon.NewQVariantString(p.MailFrom) + return qtcommon.NewQVariantString(r.From) case InputFolder: - return qtcommon.NewQVariantString(p.InputFolder) + return qtcommon.NewQVariantString(r.SourceID) case ErrorMessage: - return qtcommon.NewQVariantString(p.ErrorMessage) + return qtcommon.NewQVariantString(r.GetErrorMessage()) default: return core.NewQVariant() } } -func (s *ErrorListModel) rowCount(parent *core.QModelIndex) int { return len(s.Details) } -func (s *ErrorListModel) columnCount(parent *core.QModelIndex) int { return 1 } -func (s *ErrorListModel) roleNames() map[int]*core.QByteArray { return s.Roles() } +func (e *ErrorListModel) rowCount(parent *core.QModelIndex) int { return len(e.records) } +func (e *ErrorListModel) columnCount(parent *core.QModelIndex) int { return 1 } +func (e *ErrorListModel) roleNames() map[int]*core.QByteArray { return e.Roles() } -// Add more errors to list -func (s *ErrorListModel) Add(more []*ErrorDetail) { - s.BeginInsertRows(core.NewQModelIndex(), len(s.Details), len(s.Details)) - s.Details = append(s.Details, more...) - s.SetCount(len(s.Details)) - s.EndInsertRows() -} +func (e *ErrorListModel) load() { + if e.Progress == nil { + log.Error("Progress not connected") + return + } -// Clear removes all items in model -func (s *ErrorListModel) Clear() { - s.BeginRemoveRows(core.NewQModelIndex(), 0, len(s.Details)) - s.Details = s.Details[0:0] - s.SetCount(len(s.Details)) - s.EndRemoveRows() -} - -func (s *ErrorListModel) load(importLogFileName string) { - /* - err := backend.LoopDetailsInFile(importLogFileName, func(d *backend.MessageDetails) { - if d.MessageID != "" { // imported ok - return - } - ed := &ErrorDetail{ - MailSubject: d.Subject, - MailDate: d.Time, - MailFrom: d.From, - InputFolder: d.Folder, - ErrorMessage: d.Error, - } - s.Add([]*ErrorDetail{ed}) - }) - if err != nil { - log.Errorf("load import report from %q: %v", importLogFileName, err) - } - */ + e.BeginResetModel() + e.records = e.Progress.GetFailedMessages() + e.EndResetModel() } diff --git a/internal/frontend/qt-ie/export.go b/internal/frontend/qt-ie/export.go index d85a3cbb..e851ff02 100644 --- a/internal/frontend/qt-ie/export.go +++ b/internal/frontend/qt-ie/export.go @@ -21,6 +21,7 @@ package qtie import ( "github.com/ProtonMail/proton-bridge/internal/transfer" + "github.com/pkg/errors" ) const ( @@ -29,10 +30,11 @@ const ( ) func (f *FrontendQt) LoadStructureForExport(addressOrID string) { + errCode := errUnknownError var err error defer func() { if err != nil { - f.showError(err) + f.showError(errCode, errors.Wrap(err, "failed to load structure for "+addressOrID)) f.Qml.ExportStructureLoadFinished(false) } else { f.Qml.ExportStructureLoadFinished(true) @@ -40,20 +42,12 @@ func (f *FrontendQt) LoadStructureForExport(addressOrID string) { }() if f.transfer, err = f.ie.GetEMLExporter(addressOrID, ""); err != nil { + // The only error can be problem to load PM user and address. + errCode = errPMLoadFailed return } - f.PMStructure.Clear() - sourceMailboxes, err := f.transfer.SourceMailboxes() - if err != nil { - return - } - for _, mbox := range sourceMailboxes { - rule := f.transfer.GetRule(mbox) - f.PMStructure.addEntry(newFolderInfo(mbox, rule)) - } - - f.PMStructure.transfer = f.transfer + f.TransferRules.setTransfer(f.transfer) } func (f *FrontendQt) StartExport(rootPath, login, fileType string, attachEncryptedBody bool) { diff --git a/internal/frontend/qt-ie/frontend.go b/internal/frontend/qt-ie/frontend.go index 6b6305f5..10599904 100644 --- a/internal/frontend/qt-ie/frontend.go +++ b/internal/frontend/qt-ie/frontend.go @@ -65,11 +65,11 @@ type FrontendQt struct { programVersion string // Program version buildVersion string // Program build version - PMStructure *FolderStructure // Providing data for account labels and folders for ProtonMail account - ExternalStructure *FolderStructure // Providing data for account labels and folders for MBOX, EML or external IMAP account - ErrorList *ErrorListModel // Providing data for error reporting + TransferRules *TransferRules + ErrorList *ErrorListModel // Providing data for error reporting transfer *transfer.Transfer + progress *transfer.Progress notifyHasNoKeychain bool } @@ -103,102 +103,99 @@ func New( } // IsAppRestarting for Import-Export is always false i.e never restarts -func (s *FrontendQt) IsAppRestarting() bool { +func (f *FrontendQt) IsAppRestarting() bool { return false } // Loop function for Import-Export interface. It runs QtExecute in main thread // with no additional function. -func (s *FrontendQt) Loop(setupError error) (err error) { +func (f *FrontendQt) Loop(setupError error) (err error) { if setupError != nil { - s.notifyHasNoKeychain = true + f.notifyHasNoKeychain = true } go func() { - defer s.panicHandler.HandlePanic() - s.watchEvents() + defer f.panicHandler.HandlePanic() + f.watchEvents() }() - err = s.QtExecute(func(s *FrontendQt) error { return nil }) + err = f.QtExecute(func(f *FrontendQt) error { return nil }) return err } -func (s *FrontendQt) watchEvents() { - internetOffCh := qtcommon.MakeAndRegisterEvent(s.eventListener, events.InternetOffEvent) - internetOnCh := qtcommon.MakeAndRegisterEvent(s.eventListener, events.InternetOnEvent) - restartBridgeCh := qtcommon.MakeAndRegisterEvent(s.eventListener, events.RestartBridgeEvent) - addressChangedCh := qtcommon.MakeAndRegisterEvent(s.eventListener, events.AddressChangedEvent) - addressChangedLogoutCh := qtcommon.MakeAndRegisterEvent(s.eventListener, events.AddressChangedLogoutEvent) - logoutCh := qtcommon.MakeAndRegisterEvent(s.eventListener, events.LogoutEvent) - updateApplicationCh := qtcommon.MakeAndRegisterEvent(s.eventListener, events.UpgradeApplicationEvent) - newUserCh := qtcommon.MakeAndRegisterEvent(s.eventListener, events.UserRefreshEvent) +func (f *FrontendQt) watchEvents() { + internetOffCh := qtcommon.MakeAndRegisterEvent(f.eventListener, events.InternetOffEvent) + internetOnCh := qtcommon.MakeAndRegisterEvent(f.eventListener, events.InternetOnEvent) + restartBridgeCh := qtcommon.MakeAndRegisterEvent(f.eventListener, events.RestartBridgeEvent) + addressChangedCh := qtcommon.MakeAndRegisterEvent(f.eventListener, events.AddressChangedEvent) + addressChangedLogoutCh := qtcommon.MakeAndRegisterEvent(f.eventListener, events.AddressChangedLogoutEvent) + logoutCh := qtcommon.MakeAndRegisterEvent(f.eventListener, events.LogoutEvent) + updateApplicationCh := qtcommon.MakeAndRegisterEvent(f.eventListener, events.UpgradeApplicationEvent) + newUserCh := qtcommon.MakeAndRegisterEvent(f.eventListener, events.UserRefreshEvent) for { select { case <-internetOffCh: - s.Qml.SetConnectionStatus(false) + f.Qml.SetConnectionStatus(false) case <-internetOnCh: - s.Qml.SetConnectionStatus(true) + f.Qml.SetConnectionStatus(true) case <-restartBridgeCh: - s.Qml.SetIsRestarting(true) - s.App.Quit() + f.Qml.SetIsRestarting(true) + f.App.Quit() case address := <-addressChangedCh: - s.Qml.NotifyAddressChanged(address) + f.Qml.NotifyAddressChanged(address) case address := <-addressChangedLogoutCh: - s.Qml.NotifyAddressChangedLogout(address) + f.Qml.NotifyAddressChangedLogout(address) case userID := <-logoutCh: - user, err := s.ie.GetUser(userID) + user, err := f.ie.GetUser(userID) if err != nil { return } - s.Qml.NotifyLogout(user.Username()) + f.Qml.NotifyLogout(user.Username()) case <-updateApplicationCh: - s.Qml.ProcessFinished() - s.Qml.NotifyUpdate() + f.Qml.ProcessFinished() + f.Qml.NotifyUpdate() case <-newUserCh: - s.Qml.LoadAccounts() + f.Qml.LoadAccounts() } } } -func (s *FrontendQt) qtSetupQmlAndStructures() { - s.App = widgets.NewQApplication(len(os.Args), os.Args) +func (f *FrontendQt) qtSetupQmlAndStructures() { + f.App = widgets.NewQApplication(len(os.Args), os.Args) // view - s.View = qml.NewQQmlApplicationEngine(s.App) + f.View = qml.NewQQmlApplicationEngine(f.App) // Add Go-QML Import-Export - s.Qml = NewGoQMLInterface(nil) - s.Qml.SetFrontend(s) // provides access - s.View.RootContext().SetContextProperty("go", s.Qml) + f.Qml = NewGoQMLInterface(nil) + f.Qml.SetFrontend(f) // provides access + f.View.RootContext().SetContextProperty("go", f.Qml) + // Add AccountsModel - s.Accounts.SetupAccounts(s.Qml, s.ie) - s.View.RootContext().SetContextProperty("accountsModel", s.Accounts.Model) + f.Accounts.SetupAccounts(f.Qml, f.ie) + f.View.RootContext().SetContextProperty("accountsModel", f.Accounts.Model) - // Add ProtonMail FolderStructure - s.PMStructure = NewFolderStructure(nil) - s.View.RootContext().SetContextProperty("structurePM", s.PMStructure) - - // Add external FolderStructure - s.ExternalStructure = NewFolderStructure(nil) - s.View.RootContext().SetContextProperty("structureExternal", s.ExternalStructure) + // Add TransferRules structure + f.TransferRules = NewTransferRules(nil) + f.View.RootContext().SetContextProperty("transferRules", f.TransferRules) // Add error list modal - s.ErrorList = NewErrorListModel(nil) - s.View.RootContext().SetContextProperty("errorList", s.ErrorList) - s.Qml.ConnectLoadImportReports(s.ErrorList.load) + f.ErrorList = NewErrorListModel(nil) + f.View.RootContext().SetContextProperty("errorList", f.ErrorList) + f.Qml.ConnectLoadImportReports(f.ErrorList.load) // Import path and load QML files - s.View.AddImportPath("qrc:///") - s.View.Load(core.NewQUrl3("qrc:/uiie.qml", 0)) + f.View.AddImportPath("qrc:///") + f.View.Load(core.NewQUrl3("qrc:/uiie.qml", 0)) // TODO set the first start flag log.Error("Get FirstStart: Not implemented") //if prefs.Get(prefs.FirstStart) == "true" { if false { - s.Qml.SetIsFirstStart(true) + f.Qml.SetIsFirstStart(true) } else { - s.Qml.SetIsFirstStart(false) + f.Qml.SetIsFirstStart(false) } // Notify user about error during initialization. - if s.notifyHasNoKeychain { - s.Qml.NotifyHasNoKeychain() + if f.notifyHasNoKeychain { + f.Qml.NotifyHasNoKeychain() } } @@ -207,18 +204,18 @@ func (s *FrontendQt) qtSetupQmlAndStructures() { // It is needed to have just one Qt application per program (at least per same // thread). This functions reads the main user interface defined in QML files. // The files are appended to library by Qt-QRC. -func (s *FrontendQt) QtExecute(Procedure func(*FrontendQt) error) error { - qtcommon.QtSetupCoreAndControls(s.programName, s.programVersion) - s.qtSetupQmlAndStructures() +func (f *FrontendQt) QtExecute(Procedure func(*FrontendQt) error) error { + qtcommon.QtSetupCoreAndControls(f.programName, f.programVersion) + f.qtSetupQmlAndStructures() // Check QML is loaded properly - if len(s.View.RootObjects()) == 0 { + if len(f.View.RootObjects()) == 0 { //return errors.New(errors.ErrQApplication, "QML not loaded properly") return errors.New("QML not loaded properly") } // Obtain main window (need for invoke method) - s.MainWin = s.View.RootObjects()[0] + f.MainWin = f.View.RootObjects()[0] // Injected procedure for out-of-main-thread applications - if err := Procedure(s); err != nil { + if err := Procedure(f); err != nil { return err } // Loop @@ -234,63 +231,55 @@ func (s *FrontendQt) QtExecute(Procedure func(*FrontendQt) error) error { return nil } -func (s *FrontendQt) openLogs() { - go open.Run(s.config.GetLogDir()) +func (f *FrontendQt) openLogs() { + go open.Run(f.config.GetLogDir()) } -func (s *FrontendQt) openReport() { - go open.Run(s.Qml.ImportLogFileName()) +func (f *FrontendQt) openReport() { + go open.Run(f.Qml.ImportLogFileName()) } -func (s *FrontendQt) openDownloadLink() { - go open.Run(s.updates.GetDownloadLink()) +func (f *FrontendQt) openDownloadLink() { + go open.Run(f.updates.GetDownloadLink()) } -func (s *FrontendQt) sendImportReport(address, reportFile string) (isOK bool) { - /* - accname := "[No account logged in]" - if s.Accounts.Count() > 0 { - accname = s.Accounts.get(0).Account() - } - - basename := filepath.Base(reportFile) - req := pmapi.ReportReq{ - OS: core.QSysInfo_ProductType(), - OSVersion: core.QSysInfo_PrettyProductName(), - Title: "[Import Export] Import report: " + basename, - Description: "Sending import report file in attachment.", - Username: accname, - Email: address, - } - - report, err := os.Open(reportFile) - if err != nil { - log.Errorln("report file open:", err) - isOK = false - } - req.AddAttachment("log", basename, report) - - c := pmapi.NewClient(backend.APIConfig, "import_reporter") - err = c.Report(req) - if err != nil { - log.Errorln("while sendReport:", err) - isOK = false - return - } - log.Infof("Report %q send successfully", basename) - isOK = true - */ - return false -} - -// sendBug is almost idetical to bridge -func (s *FrontendQt) sendBug(description, emailClient, address string) (isOK bool) { - isOK = true +// sendImportReport sends an anonymized import or export report file to our customer support +func (f *FrontendQt) sendImportReport(address string) bool { // Todo_: Rename to sendReport? var accname = "No account logged in" - if s.Accounts.Model.Count() > 0 { - accname = s.Accounts.Model.Get(0).Account() + if f.Accounts.Model.Count() > 0 { + accname = f.Accounts.Model.Get(0).Account() } - if err := s.ie.ReportBug( + + if f.progress == nil { + log.Errorln("Failed to send process report: Missing progress") + return false + } + + report := f.progress.GenerateBugReport() + + if err := f.ie.ReportFile( + core.QSysInfo_ProductType(), + core.QSysInfo_PrettyProductName(), + accname, + address, + report, + ); err != nil { + log.Errorln("Failed to send process report:", err) + return false + } + + log.Info("Report send successfully") + return true +} + +// sendBug sends a bug report described by user to our customer support +func (f *FrontendQt) sendBug(description, emailClient, address string) bool { + var accname = "No account logged in" + if f.Accounts.Model.Count() > 0 { + accname = f.Accounts.Model.Get(0).Account() + } + + if err := f.ie.ReportBug( core.QSysInfo_ProductType(), core.QSysInfo_PrettyProductName(), description, @@ -299,41 +288,43 @@ func (s *FrontendQt) sendBug(description, emailClient, address string) (isOK boo emailClient, ); err != nil { log.Errorln("while sendBug:", err) - isOK = false + return false } - return + + return true } // checkInternet is almost idetical to bridge -func (s *FrontendQt) checkInternet() { - s.Qml.SetConnectionStatus(s.ie.CheckConnection() == nil) +func (f *FrontendQt) checkInternet() { + f.Qml.SetConnectionStatus(f.ie.CheckConnection() == nil) } -func (s *FrontendQt) showError(err error) { - code := 0 // TODO err.Code() - s.Qml.SetErrorDescription(err.Error()) +func (f *FrontendQt) showError(code int, err error) { + f.Qml.SetErrorDescription(err.Error()) log.WithField("code", code).Errorln(err.Error()) - s.Qml.NotifyError(code) + f.Qml.NotifyError(code) } -func (s *FrontendQt) emitEvent(evType, msg string) { - s.eventListener.Emit(evType, msg) +func (f *FrontendQt) emitEvent(evType, msg string) { + f.eventListener.Emit(evType, msg) } -func (s *FrontendQt) setProgressManager(progress *transfer.Progress) { - s.Qml.ConnectPauseProcess(func() { progress.Pause("user") }) - s.Qml.ConnectResumeProcess(progress.Resume) - s.Qml.ConnectCancelProcess(func(clearUnfinished bool) { - // TODO clear unfinished +func (f *FrontendQt) setProgressManager(progress *transfer.Progress) { + f.progress = progress + f.ErrorList.Progress = progress + + f.Qml.ConnectPauseProcess(func() { progress.Pause("paused") }) + f.Qml.ConnectResumeProcess(progress.Resume) + f.Qml.ConnectCancelProcess(func() { progress.Stop() }) go func() { defer func() { - s.Qml.DisconnectPauseProcess() - s.Qml.DisconnectResumeProcess() - s.Qml.DisconnectCancelProcess() - s.Qml.SetProgress(1) + f.Qml.DisconnectPauseProcess() + f.Qml.DisconnectResumeProcess() + f.Qml.DisconnectCancelProcess() + f.Qml.SetProgress(1) }() //TODO get log file (in old code it was here, but this is ugly place probably somewhere else) @@ -344,119 +335,123 @@ func (s *FrontendQt) setProgressManager(progress *transfer.Progress) { } failed, imported, _, _, total := progress.GetCounts() if total != 0 { // udate total - s.Qml.SetTotal(int(total)) + f.Qml.SetTotal(int(total)) } - s.Qml.SetProgressFails(int(failed)) - s.Qml.SetProgressDescription(progress.PauseReason()) // TODO add description when changing folders? + f.Qml.SetProgressFails(int(failed)) + f.Qml.SetProgressDescription(progress.PauseReason()) // TODO add description when changing folders? if total > 0 { newProgress := float32(imported+failed) / float32(total) - if newProgress >= 0 && newProgress != s.Qml.Progress() { - s.Qml.SetProgress(newProgress) - s.Qml.ProgressChanged(newProgress) + if newProgress >= 0 && newProgress != f.Qml.Progress() { + f.Qml.SetProgress(newProgress) + f.Qml.ProgressChanged(newProgress) } } } - // TODO fatal error? + if err := progress.GetFatalError(); err != nil { + f.Qml.SetProgressDescription(err.Error()) + } else { + f.Qml.SetProgressDescription("") + } }() } // StartUpdate is identical to bridge -func (s *FrontendQt) StartUpdate() { +func (f *FrontendQt) StartUpdate() { progress := make(chan updates.Progress) go func() { // Update progress in QML. - defer s.panicHandler.HandlePanic() + defer f.panicHandler.HandlePanic() for current := range progress { - s.Qml.SetProgress(current.Processed) - s.Qml.SetProgressDescription(strconv.Itoa(current.Description)) + f.Qml.SetProgress(current.Processed) + f.Qml.SetProgressDescription(strconv.Itoa(current.Description)) // Error happend if current.Err != nil { log.Error("update progress: ", current.Err) - s.Qml.UpdateFinished(true) + f.Qml.UpdateFinished(true) return } // Finished everything OK. if current.Description >= updates.InfoQuitApp { - s.Qml.UpdateFinished(false) + f.Qml.UpdateFinished(false) time.Sleep(3 * time.Second) // Just notify. - s.Qml.SetIsRestarting(current.Description == updates.InfoRestartApp) - s.App.Quit() + f.Qml.SetIsRestarting(current.Description == updates.InfoRestartApp) + f.App.Quit() return } } }() go func() { - defer s.panicHandler.HandlePanic() - s.updates.StartUpgrade(progress) + defer f.panicHandler.HandlePanic() + f.updates.StartUpgrade(progress) }() } // isNewVersionAvailable is identical to bridge // return 0 when local version is fine // return 1 when new version is available -func (s *FrontendQt) isNewVersionAvailable(showMessage bool) { +func (f *FrontendQt) isNewVersionAvailable(showMessage bool) { go func() { - defer s.Qml.ProcessFinished() - isUpToDate, latestVersionInfo, err := s.updates.CheckIsUpToDate() + defer f.Qml.ProcessFinished() + isUpToDate, latestVersionInfo, err := f.updates.CheckIsUpToDate() if err != nil { log.Warnln("Cannot retrieve version info: ", err) - s.checkInternet() + f.checkInternet() return } - s.Qml.SetConnectionStatus(true) // if we are here connection is ok + f.Qml.SetConnectionStatus(true) // if we are here connection is ok if isUpToDate { - s.Qml.SetUpdateState(StatusUpToDate) + f.Qml.SetUpdateState(StatusUpToDate) if showMessage { - s.Qml.NotifyVersionIsTheLatest() + f.Qml.NotifyVersionIsTheLatest() } return } - s.Qml.SetNewversion(latestVersionInfo.Version) - s.Qml.SetChangelog(latestVersionInfo.ReleaseNotes) - s.Qml.SetBugfixes(latestVersionInfo.ReleaseFixedBugs) - s.Qml.SetLandingPage(latestVersionInfo.LandingPage) - s.Qml.SetDownloadLink(latestVersionInfo.GetDownloadLink()) - s.Qml.SetUpdateState(StatusNewVersionAvailable) + f.Qml.SetNewversion(latestVersionInfo.Version) + f.Qml.SetChangelog(latestVersionInfo.ReleaseNotes) + f.Qml.SetBugfixes(latestVersionInfo.ReleaseFixedBugs) + f.Qml.SetLandingPage(latestVersionInfo.LandingPage) + f.Qml.SetDownloadLink(latestVersionInfo.GetDownloadLink()) + f.Qml.SetUpdateState(StatusNewVersionAvailable) }() } -func (s *FrontendQt) resetSource() { - if s.transfer != nil { - s.transfer.ResetRules() - if err := s.loadStructuresForImport(); err != nil { +func (f *FrontendQt) resetSource() { + if f.transfer != nil { + f.transfer.ResetRules() + if err := f.loadStructuresForImport(); err != nil { log.WithError(err).Error("Cannot reload structures after reseting rules.") } } } // getLocalVersionInfo is identical to bridge. -func (s *FrontendQt) getLocalVersionInfo() { - defer s.Qml.ProcessFinished() - localVersion := s.updates.GetLocalVersion() - s.Qml.SetNewversion(localVersion.Version) - s.Qml.SetChangelog(localVersion.ReleaseNotes) - s.Qml.SetBugfixes(localVersion.ReleaseFixedBugs) +func (f *FrontendQt) getLocalVersionInfo() { + defer f.Qml.ProcessFinished() + localVersion := f.updates.GetLocalVersion() + f.Qml.SetNewversion(localVersion.Version) + f.Qml.SetChangelog(localVersion.ReleaseNotes) + f.Qml.SetBugfixes(localVersion.ReleaseFixedBugs) } // LeastUsedColor is intended to return color for creating a new inbox or label. -func (s *FrontendQt) leastUsedColor() string { - if s.transfer == nil { +func (f *FrontendQt) leastUsedColor() string { + if f.transfer == nil { log.Errorln("Getting least used color before transfer exist.") return "#7272a7" } - m, err := s.transfer.TargetMailboxes() + m, err := f.transfer.TargetMailboxes() if err != nil { log.Errorln("Getting least used color:", err) - s.showError(err) + f.showError(errUnknownError, err) } return transfer.LeastUsedColor(m) } // createLabelOrFolder performs an IE target mailbox creation. -func (s *FrontendQt) createLabelOrFolder(email, name, color string, isLabel bool, sourceID string) bool { +func (f *FrontendQt) createLabelOrFolder(email, name, color string, isLabel bool, sourceID string) bool { // Prepare new mailbox. m := transfer.Mailbox{ Name: name, @@ -466,32 +461,28 @@ func (s *FrontendQt) createLabelOrFolder(email, name, color string, isLabel bool // Select least used color if no color given. if m.Color == "" { - m.Color = s.leastUsedColor() + m.Color = f.leastUsedColor() } + f.TransferRules.BeginResetModel() + defer f.TransferRules.EndResetModel() + // Create mailbox. - newLabel, err := s.transfer.CreateTargetMailbox(m) - + m, err := f.transfer.CreateTargetMailbox(m) if err != nil { log.Errorln("Folder/Label creating:", err) - s.showError(err) - return false - } - - // TODO: notify UI of newly added folders/labels - /*errc := s.PMStructure.Load(email, false) - if errc != nil { - s.showError(errc) - return false - }*/ - - if sourceID != "" { if isLabel { - s.ExternalStructure.addTargetLabelID(sourceID, newLabel.ID) + f.showError(errCreateLabelFailed, err) } else { - s.ExternalStructure.setTargetFolderID(sourceID, newLabel.ID) + f.showError(errCreateFolderFailed, err) } + return false } + if sourceID == "-1" { + f.transfer.SetGlobalMailbox(&m) + } else { + f.TransferRules.addTargetID(sourceID, m.Hash()) + } return true } diff --git a/internal/frontend/qt-ie/import.go b/internal/frontend/qt-ie/import.go index 058dff58..6e80dda9 100644 --- a/internal/frontend/qt-ie/import.go +++ b/internal/frontend/qt-ie/import.go @@ -19,14 +19,19 @@ package qtie -import "github.com/ProtonMail/proton-bridge/internal/transfer" +import ( + "github.com/pkg/errors" + + "github.com/ProtonMail/proton-bridge/internal/transfer" +) // wrapper for QML func (f *FrontendQt) setupAndLoadForImport(isFromIMAP bool, sourcePath, sourceEmail, sourcePassword, sourceServer, sourcePort, targetAddress string) { + errCode := errUnknownError var err error defer func() { if err != nil { - f.showError(err) + f.showError(errCode, err) f.Qml.ImportStructuresLoadFinished(false) } else { f.Qml.ImportStructuresLoadFinished(true) @@ -36,11 +41,23 @@ func (f *FrontendQt) setupAndLoadForImport(isFromIMAP bool, sourcePath, sourceEm if isFromIMAP { f.transfer, err = f.ie.GetRemoteImporter(targetAddress, sourceEmail, sourcePassword, sourceServer, sourcePort) if err != nil { + switch { + case errors.Is(err, &transfer.ErrIMAPConnection{}): + errCode = errWrongServerPathOrPort + case errors.Is(err, &transfer.ErrIMAPAuth{}): + errCode = errWrongLoginOrPassword + case errors.Is(err, &transfer.ErrIMAPAuthMethod{}): + errCode = errWrongAuthMethod + default: + errCode = errRemoteSourceLoadFailed + } return } } else { f.transfer, err = f.ie.GetLocalImporter(targetAddress, sourcePath) if err != nil { + // The only error can be problem to load PM user and address. + errCode = errPMLoadFailed return } } @@ -51,27 +68,7 @@ func (f *FrontendQt) setupAndLoadForImport(isFromIMAP bool, sourcePath, sourceEm } func (f *FrontendQt) loadStructuresForImport() error { - f.PMStructure.Clear() - targetMboxes, err := f.transfer.TargetMailboxes() - if err != nil { - return err - } - for _, mbox := range targetMboxes { - rule := &transfer.Rule{} - f.PMStructure.addEntry(newFolderInfo(mbox, rule)) - } - - f.ExternalStructure.Clear() - sourceMboxes, err := f.transfer.SourceMailboxes() - if err != nil { - return err - } - for _, mbox := range sourceMboxes { - rule := f.transfer.GetRule(mbox) - f.ExternalStructure.addEntry(newFolderInfo(mbox, rule)) - } - - f.ExternalStructure.transfer = f.transfer + f.TransferRules.setTransfer(f.transfer) return nil } @@ -82,8 +79,9 @@ func (f *FrontendQt) StartImport(email string) { // TODO email not needed f.Qml.SetProgress(0.0) f.Qml.SetTotal(1) f.Qml.SetImportLogFileName("") - f.ErrorList.Clear() progress := f.transfer.Start() + + f.Qml.SetImportLogFileName(progress.FileReport()) f.setProgressManager(progress) } diff --git a/internal/frontend/qt-ie/mbox.go b/internal/frontend/qt-ie/mbox.go new file mode 100644 index 00000000..7f791c89 --- /dev/null +++ b/internal/frontend/qt-ie/mbox.go @@ -0,0 +1,188 @@ +// Copyright (c) 2020 Proton Technologies AG + +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build !nogui + +package qtie + +import ( + qtcommon "github.com/ProtonMail/proton-bridge/internal/frontend/qt-common" + "github.com/ProtonMail/proton-bridge/internal/transfer" + "github.com/sirupsen/logrus" + "github.com/therecipe/qt/core" +) + +// MboxList is an interface between QML and targets for given rule. +type MboxList struct { + core.QAbstractListModel + + containsFolders bool // Provides only folders if true. On the other hand provides only labels if false + transfer *transfer.Transfer + rule *transfer.Rule + log *logrus.Entry + + _ int `property:"selectedIndex"` + + _ func() `constructor:"init"` +} + +func init() { + // This is needed so the type exists in QML files. + MboxList_QRegisterMetaType() +} + +func newMboxList(t *TransferRules, rule *transfer.Rule, containsFolders bool) *MboxList { + m := NewMboxList(t) + m.BeginResetModel() + m.transfer = t.transfer + m.rule = rule + m.containsFolders = containsFolders + m.log = log. + WithField("rule", m.rule.SourceMailbox.Hash()). + WithField("folders", m.containsFolders) + m.EndResetModel() + m.itemsChanged(rule) + return m +} + +func (m *MboxList) init() { + m.ConnectRowCount(m.rowCount) + m.ConnectRoleNames(m.roleNames) + m.ConnectData(m.data) +} + +func (m *MboxList) rowCount(index *core.QModelIndex) int { + return len(m.targetMailboxes()) +} + +func (m *MboxList) roleNames() map[int]*core.QByteArray { + m.log. + WithField("isActive", MboxIsActive). + WithField("id", MboxID). + WithField("color", MboxColor). + Debug("role names") + return map[int]*core.QByteArray{ + MboxIsActive: qtcommon.NewQByteArrayFromString("isActive"), + MboxID: qtcommon.NewQByteArrayFromString("mboxID"), + MboxName: qtcommon.NewQByteArrayFromString("name"), + MboxType: qtcommon.NewQByteArrayFromString("type"), + MboxColor: qtcommon.NewQByteArrayFromString("iconColor"), + } +} + +func (m *MboxList) data(index *core.QModelIndex, role int) *core.QVariant { + allTargets := m.targetMailboxes() + + i, valid := index.Row(), index.IsValid() + l := m.log.WithField("row", i).WithField("role", role) + l.Trace("called data()") + + if !valid || i >= len(allTargets) { + l.WithField("row", i).Warning("Invalid index") + return core.NewQVariant() + } + + if m.transfer == nil { + l.Warning("Requested mbox list data before transfer is connected") + return qtcommon.NewQVariantString("") + } + + mbox := allTargets[i] + + switch role { + + case MboxIsActive: + for _, selectedMailbox := range m.rule.TargetMailboxes { + if selectedMailbox.Hash() == mbox.Hash() { + return qtcommon.NewQVariantBool(true) + } + } + return qtcommon.NewQVariantBool(false) + + case MboxID: + return qtcommon.NewQVariantString(mbox.Hash()) + + case MboxName, int(core.Qt__DisplayRole): + return qtcommon.NewQVariantString(mbox.Name) + + case MboxType: + t := "label" + if mbox.IsExclusive { + t = "folder" + } + return qtcommon.NewQVariantString(t) + + case MboxColor: + return qtcommon.NewQVariantString(mbox.Color) + + default: + l.Error("Requested mbox list data with unknown role") + return qtcommon.NewQVariantString("") + } +} + +func (m *MboxList) targetMailboxes() []transfer.Mailbox { + if m.transfer == nil { + m.log.Warning("Requested target mailboxes before transfer is connected") + } + + mailboxes, err := m.transfer.TargetMailboxes() + if err != nil { + m.log.WithError(err).Error("Unable to get target mailboxes") + } + + return m.filter(mailboxes) +} + +func (m *MboxList) filter(mailboxes []transfer.Mailbox) (filtered []transfer.Mailbox) { + for _, mailbox := range mailboxes { + if mailbox.IsExclusive == m.containsFolders { + filtered = append(filtered, mailbox) + } + } + return +} + +func (m *MboxList) itemsChanged(rule *transfer.Rule) { + m.rule = rule + allTargets := m.targetMailboxes() + l := m.log.WithField("count", len(allTargets)) + l.Trace("called itemChanged()") + defer func() { + l.WithField("selected", m.SelectedIndex()).Trace("index updated") + }() + + // NOTE: Be careful with indices: If they are invalid the DataChanged + // signal will not be sent to QML e.g. `end == rowCount - 1` + if len(allTargets) > 0 { + begin := m.Index(0, 0, core.NewQModelIndex()) + end := m.Index(len(allTargets)-1, 0, core.NewQModelIndex()) + changedRoles := []int{MboxIsActive} + m.DataChanged(begin, end, changedRoles) + } + + for index, targetMailbox := range allTargets { + for _, selectedTarget := range m.rule.TargetMailboxes { + if targetMailbox.Hash() == selectedTarget.Hash() { + m.SetSelectedIndex(index) + return + } + } + } + m.SetSelectedIndex(-1) +} diff --git a/internal/frontend/qt-ie/transfer_rules.go b/internal/frontend/qt-ie/transfer_rules.go new file mode 100644 index 00000000..8b88f94e --- /dev/null +++ b/internal/frontend/qt-ie/transfer_rules.go @@ -0,0 +1,377 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build !nogui + +package qtie + +import ( + qtcommon "github.com/ProtonMail/proton-bridge/internal/frontend/qt-common" + "github.com/ProtonMail/proton-bridge/internal/transfer" + "github.com/therecipe/qt/core" +) + +// TransferRules is an interface between QML and transfer. +type TransferRules struct { + core.QAbstractListModel + + transfer *transfer.Transfer + + targetFoldersCache map[string]*MboxList + targetLabelsCache map[string]*MboxList + + _ func() `constructor:"init"` + + _ func(sourceID string) *MboxList `slot:"targetFolders,auto"` + _ func(sourceID string) *MboxList `slot:"targetLabels,auto"` + _ func(sourceID string, isActive bool) `slot:"setIsRuleActive,auto"` + _ func(groupName string, isActive bool) `slot:"setIsGroupActive,auto"` + _ func(sourceID string, fromDate int64, toDate int64) `slot:"setFromToDate,auto"` + _ func(sourceID string, targetID string) `slot:"addTargetID,auto"` + _ func(sourceID string, targetID string) `slot:"removeTargetID,auto"` + + _ int `property:"globalFromDate"` + _ int `property:"globalToDate"` + _ bool `property:"isLabelGroupSelected"` + _ bool `property:"isFolderGroupSelected"` +} + +func init() { + // This is needed so the type exists in QML files. + TransferRules_QRegisterMetaType() +} + +func (t *TransferRules) init() { + log.Trace("Initializing transfer rules") + + t.targetFoldersCache = make(map[string]*MboxList) + t.targetLabelsCache = make(map[string]*MboxList) + + t.SetGlobalFromDate(0) + t.SetGlobalToDate(0) + + t.ConnectRowCount(t.rowCount) + t.ConnectRoleNames(t.roleNames) + t.ConnectData(t.data) +} + +func (t *TransferRules) rowCount(index *core.QModelIndex) int { + if t.transfer == nil { + return 0 + } + return len(t.transfer.GetRules()) +} + +func (t *TransferRules) roleNames() map[int]*core.QByteArray { + return map[int]*core.QByteArray{ + MboxIsActive: qtcommon.NewQByteArrayFromString("isActive"), + MboxID: qtcommon.NewQByteArrayFromString("mboxID"), + MboxName: qtcommon.NewQByteArrayFromString("name"), + MboxType: qtcommon.NewQByteArrayFromString("type"), + MboxColor: qtcommon.NewQByteArrayFromString("iconColor"), + RuleTargetLabelColors: qtcommon.NewQByteArrayFromString("labelColors"), + RuleFromDate: qtcommon.NewQByteArrayFromString("fromDate"), + RuleToDate: qtcommon.NewQByteArrayFromString("toDate"), + } +} + +func (t *TransferRules) data(index *core.QModelIndex, role int) *core.QVariant { + i, valid := index.Row(), index.IsValid() + + if !valid || i >= t.rowCount(index) { + log.WithField("row", i).Warning("Invalid index") + return core.NewQVariant() + } + + log := log.WithField("row", i).WithField("role", role) + + if t.transfer == nil { + log.Warning("Requested transfer rules data before transfer is connected") + return qtcommon.NewQVariantString("") + } + + rule := t.transfer.GetRules()[i] + + switch role { + case MboxIsActive: + return qtcommon.NewQVariantBool(rule.Active) + + case MboxID: + return qtcommon.NewQVariantString(rule.SourceMailbox.Hash()) + + case MboxName: + return qtcommon.NewQVariantString(rule.SourceMailbox.Name) + + case MboxType: + if rule.SourceMailbox.IsSystemFolder() { + return qtcommon.NewQVariantString(FolderTypeSystem) + } + if rule.SourceMailbox.IsExclusive { + return qtcommon.NewQVariantString(FolderTypeFolder) + } + return qtcommon.NewQVariantString(FolderTypeLabel) + + case MboxColor: + return qtcommon.NewQVariantString(rule.SourceMailbox.Color) + + case RuleTargetLabelColors: + colors := "" + for _, m := range rule.TargetMailboxes { + if m.IsExclusive { + continue + } + if colors != "" { + colors += ";" + } + colors += m.Color + } + return qtcommon.NewQVariantString(colors) + + case RuleFromDate: + return qtcommon.NewQVariantLong(rule.FromTime) + + case RuleToDate: + return qtcommon.NewQVariantLong(rule.ToTime) + + default: + log.Error("Requested transfer rules data with unknown role") + return qtcommon.NewQVariantString("") + } +} + +func (t *TransferRules) setTransfer(transfer *transfer.Transfer) { + log.Debug("Setting transfer") + t.BeginResetModel() + defer t.EndResetModel() + + t.transfer = transfer + + t.updateGroupSelection() +} + +// Getters + +func (t *TransferRules) targetFolders(sourceID string) *MboxList { + rule := t.getRule(sourceID) + if rule == nil { + return nil + } + + if t.targetFoldersCache[sourceID] == nil { + log.WithField("source", sourceID).Debug("New target folder") + t.targetFoldersCache[sourceID] = newMboxList(t, rule, true) + } + + return t.targetFoldersCache[sourceID] +} + +func (t *TransferRules) targetLabels(sourceID string) *MboxList { + rule := t.getRule(sourceID) + if rule == nil { + return nil + } + + if t.targetLabelsCache[sourceID] == nil { + log.WithField("source", sourceID).Debug("New target label") + t.targetLabelsCache[sourceID] = newMboxList(t, rule, false) + } + + return t.targetLabelsCache[sourceID] +} + +// Setters + +func (t *TransferRules) setIsGroupActive(groupName string, isActive bool) { + wantExclusive := (groupName == FolderTypeLabel) + for _, rule := range t.transfer.GetRules() { + if rule.SourceMailbox.IsExclusive != wantExclusive { + continue + } + if rule.SourceMailbox.IsSystemFolder() { + continue + } + if rule.Active != isActive { + t.setIsRuleActive(rule.SourceMailbox.Hash(), isActive) + } + } +} + +func (t *TransferRules) setIsRuleActive(sourceID string, isActive bool) { + log.WithField("source", sourceID).WithField("active", isActive).Trace("Setting rule as active/inactive") + + rule := t.getRule(sourceID) + if rule == nil { + return + } + if isActive { + t.setRule(rule.SourceMailbox, rule.TargetMailboxes, rule.FromTime, rule.ToTime, []int{MboxIsActive}) + } else { + t.unsetRule(rule.SourceMailbox) + } +} + +func (t *TransferRules) setFromToDate(sourceID string, fromTime int64, toTime int64) { + log.WithField("source", sourceID).WithField("fromTime", fromTime).WithField("toTime", toTime).Trace("Setting from and to dates") + + if sourceID == "-1" { + t.transfer.SetGlobalTimeLimit(fromTime, toTime) + return + } + + rule := t.getRule(sourceID) + if rule == nil { + return + } + t.setRule(rule.SourceMailbox, rule.TargetMailboxes, fromTime, toTime, []int{RuleFromDate, RuleToDate}) +} + +func (t *TransferRules) addTargetID(sourceID string, targetID string) { + log.WithField("source", sourceID).WithField("target", targetID).Trace("Adding target") + + rule := t.getRule(sourceID) + if rule == nil { + return + } + targetMailboxToAdd := t.getMailbox(t.transfer.TargetMailboxes, targetID) + if targetMailboxToAdd == nil { + return + } + + newTargetMailboxes := []transfer.Mailbox{} + found := false + for _, targetMailbox := range rule.TargetMailboxes { + if targetMailbox.Hash() == targetMailboxToAdd.Hash() { + found = true + } + if !targetMailboxToAdd.IsExclusive || (targetMailboxToAdd.IsExclusive && !targetMailbox.IsExclusive) { + newTargetMailboxes = append(newTargetMailboxes, targetMailbox) + } + } + if !found { + newTargetMailboxes = append(newTargetMailboxes, *targetMailboxToAdd) + } + t.setRule(rule.SourceMailbox, newTargetMailboxes, rule.FromTime, rule.ToTime, []int{RuleTargetLabelColors}) +} + +func (t *TransferRules) removeTargetID(sourceID string, targetID string) { + log.WithField("source", sourceID).WithField("target", targetID).Trace("Removing target") + + rule := t.getRule(sourceID) + if rule == nil { + return + } + targetMailboxToRemove := t.getMailbox(t.transfer.TargetMailboxes, targetID) + if targetMailboxToRemove == nil { + return + } + + newTargetMailboxes := []transfer.Mailbox{} + for _, targetMailbox := range rule.TargetMailboxes { + if targetMailbox.Hash() != targetMailboxToRemove.Hash() { + newTargetMailboxes = append(newTargetMailboxes, targetMailbox) + } + } + t.setRule(rule.SourceMailbox, newTargetMailboxes, rule.FromTime, rule.ToTime, []int{RuleTargetLabelColors}) +} + +// Helpers + +func (t *TransferRules) getRule(sourceID string) *transfer.Rule { + mailbox := t.getMailbox(t.transfer.SourceMailboxes, sourceID) + if mailbox == nil { + return nil + } + return t.transfer.GetRule(*mailbox) +} + +func (t *TransferRules) getMailbox(mailboxesGetter func() ([]transfer.Mailbox, error), sourceID string) *transfer.Mailbox { + if t.transfer == nil { + log.Warn("Getting mailbox without avaiable transfer") + return nil + } + + mailboxes, err := mailboxesGetter() + if err != nil { + log.WithError(err).Error("Failed to get source mailboxes") + return nil + } + for _, mailbox := range mailboxes { + if mailbox.Hash() == sourceID { + return &mailbox + } + } + log.WithField("source", sourceID).Error("Mailbox not found for source") + return nil +} + +func (t *TransferRules) setRule(sourceMailbox transfer.Mailbox, targetMailboxes []transfer.Mailbox, fromTime, toTime int64, changedRoles []int) { + if err := t.transfer.SetRule(sourceMailbox, targetMailboxes, fromTime, toTime); err != nil { + log.WithError(err).WithField("source", sourceMailbox.Hash()).Error("Failed to set rule") + } + t.ruleChanged(sourceMailbox, changedRoles) +} + +func (t *TransferRules) unsetRule(sourceMailbox transfer.Mailbox) { + t.transfer.UnsetRule(sourceMailbox) + t.ruleChanged(sourceMailbox, []int{MboxIsActive}) +} + +func (t *TransferRules) ruleChanged(sourceMailbox transfer.Mailbox, changedRoles []int) { + for row, rule := range t.transfer.GetRules() { + if rule.SourceMailbox.Hash() != sourceMailbox.Hash() { + continue + } + + t.targetFolders(sourceMailbox.Hash()).itemsChanged(rule) + t.targetLabels(sourceMailbox.Hash()).itemsChanged(rule) + + index := t.Index(row, 0, core.NewQModelIndex()) + if !index.IsValid() || row >= t.rowCount(index) { + log.WithField("row", row).Warning("Invalid index") + return + } + + t.DataChanged(index, index, changedRoles) + break + } + + t.updateGroupSelection() +} + +func (t *TransferRules) updateGroupSelection() { + areAllLabelsSelected, areAllFoldersSelected := true, true + for _, rule := range t.transfer.GetRules() { + if rule.Active { + continue + } + if rule.SourceMailbox.IsSystemFolder() { + continue + } + if rule.SourceMailbox.IsExclusive { + areAllFoldersSelected = false + } else { + areAllLabelsSelected = false + } + + if !areAllLabelsSelected && !areAllFoldersSelected { + break + } + } + + t.SetIsLabelGroupSelected(areAllLabelsSelected) + t.SetIsFolderGroupSelected(areAllFoldersSelected) +} diff --git a/internal/frontend/qt-ie/ui.go b/internal/frontend/qt-ie/ui.go index e70663ae..c9414de8 100644 --- a/internal/frontend/qt-ie/ui.go +++ b/internal/frontend/qt-ie/ui.go @@ -71,7 +71,7 @@ type GoQMLInterface struct { _ func() `signal:"openManual"` _ func(showMessage bool) `signal:"runCheckVersion"` _ func() `slot:"getLocalVersionInfo"` - _ func(fname string) `slot:"loadImportReports"` + _ func() `slot:"loadImportReports"` _ func() `slot:"quit"` _ func() `slot:"loadAccounts"` @@ -87,7 +87,7 @@ type GoQMLInterface struct { _ func() string `slot:"getBackendVersion"` _ func(description, client, address string) bool `slot:"sendBug"` - _ func(address, fname string) bool `slot:"sendImportReport"` + _ func(address string) bool `slot:"sendImportReport"` _ func(address string) `slot:"loadStructureForExport"` _ func() string `slot:"leastUsedColor"` _ func(username string, name string, color string, isLabel bool, sourceID string) bool `slot:"createLabelOrFolder"` @@ -104,13 +104,13 @@ type GoQMLInterface struct { _ func(evType string, msg string) `signal:"emitEvent"` _ func(tabIndex int, message string) `signal:"notifyBubble"` - _ func() `signal:"bubbleClosed"` - _ func() `signal:"simpleErrorHappen"` - _ func() `signal:"askErrorHappen"` - _ func() `signal:"retryErrorHappen"` - _ func() `signal:"pauseProcess"` - _ func() `signal:"resumeProcess"` - _ func(clearUnfinished bool) `signal:"cancelProcess"` + _ func() `signal:"bubbleClosed"` + _ func() `signal:"simpleErrorHappen"` + _ func() `signal:"askErrorHappen"` + _ func() `signal:"retryErrorHappen"` + _ func() `signal:"pauseProcess"` + _ func() `signal:"resumeProcess"` + _ func() `signal:"cancelProcess"` _ func(iAccount int, prefRem bool) `slot:"deleteAccount"` _ func(iAccount int) `slot:"logoutAccount"` diff --git a/internal/frontend/types/types.go b/internal/frontend/types/types.go index f5781421..83d61b66 100644 --- a/internal/frontend/types/types.go +++ b/internal/frontend/types/types.go @@ -114,6 +114,7 @@ type ImportExporter interface { GetMBOXExporter(string, string) (*transfer.Transfer, error) SetCurrentOS(os string) ReportBug(osType, osVersion, description, accountName, address, emailClient string) error + ReportFile(osType, osVersion, accountName, address string, logdata []byte) error } type importExportWrap struct { diff --git a/internal/importexport/credits.go b/internal/importexport/credits.go index 311da61d..b10c5df9 100644 --- a/internal/importexport/credits.go +++ b/internal/importexport/credits.go @@ -15,8 +15,8 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -// Code generated by ./credits.sh at Mon Jul 13 14:02:21 CEST 2020. DO NOT EDIT. +// Code generated by ./credits.sh at Fri 07 Aug 2020 06:34:27 AM CEST. DO NOT EDIT. package importexport -const Credits = "github.com/0xAX/notificator;github.com/ProtonMail/bcrypt;github.com/ProtonMail/crypto;github.com/ProtonMail/docker-credential-helpers;github.com/ProtonMail/go-appdir;github.com/ProtonMail/go-apple-mobileconfig;github.com/ProtonMail/go-autostart;github.com/ProtonMail/go-imap-id;github.com/ProtonMail/go-smtp;github.com/ProtonMail/go-vcard;github.com/ProtonMail/gopenpgp/v2;github.com/abiosoft/ishell;github.com/abiosoft/readline;github.com/allan-simon/go-singleinstance;github.com/andybalholm/cascadia;github.com/certifi/gocertifi;github.com/chzyer/logex;github.com/chzyer/test;github.com/cucumber/godog;github.com/docker/docker-credential-helpers;github.com/emersion/go-imap;github.com/emersion/go-imap-appendlimit;github.com/emersion/go-imap-idle;github.com/emersion/go-imap-move;github.com/emersion/go-imap-quota;github.com/emersion/go-imap-specialuse;github.com/emersion/go-imap-unselect;github.com/emersion/go-mbox;github.com/emersion/go-message;github.com/emersion/go-sasl;github.com/emersion/go-smtp;github.com/emersion/go-textwrapper;github.com/emersion/go-vcard;github.com/fatih/color;github.com/flynn-archive/go-shlex;github.com/getsentry/raven-go;github.com/go-resty/resty/v2;github.com/golang/mock;github.com/google/go-cmp;github.com/gopherjs/gopherjs;github.com/hashicorp/go-multierror;github.com/jameskeane/bcrypt;github.com/jaytaylor/html2text;github.com/jhillyerd/enmime;github.com/kardianos/osext;github.com/keybase/go-keychain;github.com/logrusorgru/aurora;github.com/miekg/dns;github.com/myesui/uuid;github.com/nsf/jsondiff;github.com/pkg/errors;github.com/sirupsen/logrus;github.com/skratchdot/open-golang;github.com/stretchr/testify;github.com/therecipe/qt;github.com/twinj/uuid;github.com/urfave/cli;go.etcd.io/bbolt;golang.org/x/crypto;golang.org/x/net;golang.org/x/text;gopkg.in/stretchr/testify.v1;;Font Awesome 4.7.0;;Qt 5.13 by Qt group;" +const Credits = "github.com/0xAX/notificator;github.com/abiosoft/ishell;github.com/abiosoft/readline;github.com/allan-simon/go-singleinstance;github.com/andybalholm/cascadia;github.com/certifi/gocertifi;github.com/chzyer/logex;github.com/chzyer/test;github.com/cucumber/godog;github.com/docker/docker-credential-helpers;github.com/emersion/go-imap;github.com/emersion/go-imap-appendlimit;github.com/emersion/go-imap-idle;github.com/emersion/go-imap-move;github.com/emersion/go-imap-quota;github.com/emersion/go-imap-specialuse;github.com/emersion/go-imap-unselect;github.com/emersion/go-mbox;github.com/emersion/go-message;github.com/emersion/go-sasl;github.com/emersion/go-smtp;github.com/emersion/go-textwrapper;github.com/emersion/go-vcard;github.com/fatih/color;github.com/flynn-archive/go-shlex;github.com/getsentry/raven-go;github.com/go-delve/delve;github.com/golang/mock;github.com/google/go-cmp;github.com/gopherjs/gopherjs;github.com/go-resty/resty/v2;github.com/hashicorp/go-multierror;github.com/jameskeane/bcrypt;github.com/jaytaylor/html2text;github.com/jhillyerd/enmime;github.com/kardianos/osext;github.com/keybase/go-keychain;github.com/logrusorgru/aurora;github.com/miekg/dns;github.com/myesui/uuid;github.com/nsf/jsondiff;github.com/pkg/errors;github.com/ProtonMail/bcrypt;github.com/ProtonMail/crypto;github.com/ProtonMail/docker-credential-helpers;github.com/ProtonMail/go-appdir;github.com/ProtonMail/go-apple-mobileconfig;github.com/ProtonMail/go-autostart;github.com/ProtonMail/go-imap-id;github.com/ProtonMail/gopenpgp/v2;github.com/ProtonMail/go-smtp;github.com/ProtonMail/go-vcard;github.com/psampaz/go-mod-outdated;github.com/sirupsen/logrus;github.com/skratchdot/open-golang;github.com/stretchr/testify;github.com/therecipe/qt;github.com/twinj/uuid;github.com/urfave/cli;go.etcd.io/bbolt;golang.org/x/crypto;golang.org/x/net;golang.org/x/text;gopkg.in/stretchr/testify.v1;;Font Awesome 4.7.0;;Qt 5.13 by Qt group;" diff --git a/internal/importexport/importexport.go b/internal/importexport/importexport.go index ceaf7176..761c2992 100644 --- a/internal/importexport/importexport.go +++ b/internal/importexport/importexport.go @@ -19,8 +19,11 @@ package importexport import ( + "bytes" + "github.com/ProtonMail/proton-bridge/internal/transfer" "github.com/ProtonMail/proton-bridge/internal/users" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" "github.com/ProtonMail/proton-bridge/pkg/listener" logrus "github.com/sirupsen/logrus" @@ -61,15 +64,17 @@ func (ie *ImportExport) ReportBug(osType, osVersion, description, accountName, a defer c.Logout() title := "[Import-Export] Bug" - if err := c.ReportBugWithEmailClient( - osType, - osVersion, - title, - description, - accountName, - address, - emailClient, - ); err != nil { + report := pmapi.ReportReq{ + OS: osType, + OSVersion: osVersion, + Browser: emailClient, + Title: title, + Description: description, + Username: accountName, + Email: address, + } + + if err := c.Report(report); err != nil { log.Error("Reporting bug failed: ", err) return err } @@ -79,6 +84,35 @@ func (ie *ImportExport) ReportBug(osType, osVersion, description, accountName, a return nil } +// ReportFile submits import report file +func (ie *ImportExport) ReportFile(osType, osVersion, accountName, address string, logdata []byte) error { + c := ie.clientManager.GetAnonymousClient() + defer c.Logout() + + title := "[Import-Export] report file" + description := "An import/export report from the user swam down the river." + + report := pmapi.ReportReq{ + OS: osType, + OSVersion: osVersion, + Description: description, + Title: title, + Username: accountName, + Email: address, + } + + report.AddAttachment("log", "report.log", bytes.NewReader(logdata)) + + if err := c.Report(report); err != nil { + log.Error("Sending report failed: ", err) + return err + } + + log.Info("Report successfully sent") + + return nil +} + // GetLocalImporter returns transferrer from local EML or MBOX structure to ProtonMail account. func (ie *ImportExport) GetLocalImporter(address, path string) (*transfer.Transfer, error) { source := transfer.NewLocalProvider(path) @@ -130,7 +164,7 @@ func (ie *ImportExport) getPMAPIProvider(address string) (*transfer.PMAPIProvide addressID, err := user.GetAddressID(address) if err != nil { - return nil, err + log.WithError(err).Info("Address does not exist, using all addresses") } return transfer.NewPMAPIProvider(ie.clientManager, user.ID(), addressID) diff --git a/internal/importexport/release_notes.go b/internal/importexport/release_notes.go index 71713e72..c922d763 100644 --- a/internal/importexport/release_notes.go +++ b/internal/importexport/release_notes.go @@ -15,7 +15,7 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -// Code generated by ./release-notes.sh at 'Thu Jun 25 10:06:16 CEST 2020'. DO NOT EDIT. +// Code generated by ./release-notes.sh at 'Fri 07 Aug 2020 06:34:27 AM CEST'. DO NOT EDIT. package importexport diff --git a/internal/store/mocks/mocks.go b/internal/store/mocks/mocks.go index 8fd5c2d6..467863be 100644 --- a/internal/store/mocks/mocks.go +++ b/internal/store/mocks/mocks.go @@ -5,10 +5,9 @@ package mocks import ( - reflect "reflect" - pmapi "github.com/ProtonMail/proton-bridge/pkg/pmapi" gomock "github.com/golang/mock/gomock" + reflect "reflect" ) // MockPanicHandler is a mock of PanicHandler interface diff --git a/internal/store/mocks/utils_mocks.go b/internal/store/mocks/utils_mocks.go index 940bf172..3f43bb43 100644 --- a/internal/store/mocks/utils_mocks.go +++ b/internal/store/mocks/utils_mocks.go @@ -5,10 +5,9 @@ package mocks import ( + gomock "github.com/golang/mock/gomock" reflect "reflect" time "time" - - gomock "github.com/golang/mock/gomock" ) // MockListener is a mock of Listener interface diff --git a/internal/transfer/mailbox.go b/internal/transfer/mailbox.go index 430db7c9..db300a54 100644 --- a/internal/transfer/mailbox.go +++ b/internal/transfer/mailbox.go @@ -33,6 +33,11 @@ type Mailbox struct { IsExclusive bool } +// IsSystemFolder returns true when ID corresponds to PM system folder. +func (m Mailbox) IsSystemFolder() bool { + return pmapi.IsSystemLabel(m.ID) +} + // Hash returns unique identifier to be used for matching. func (m Mailbox) Hash() string { return fmt.Sprintf("%x", sha256.Sum256([]byte(m.Name))) diff --git a/internal/transfer/progress.go b/internal/transfer/progress.go index 28c37f38..0798bed5 100644 --- a/internal/transfer/progress.go +++ b/internal/transfer/progress.go @@ -198,7 +198,7 @@ func (p *Progress) callWrap(callback func() error) { break } - p.Pause(err.Error()) + p.Pause("paused due to " + err.Error()) } } @@ -333,3 +333,11 @@ func (p *Progress) GenerateBugReport() []byte { } return bugReport.getData() } + +func (p *Progress) FileReport() (path string) { + if r := p.fileReport; r != nil { + path = r.path + } + + return +} diff --git a/internal/transfer/provider_eml.go b/internal/transfer/provider_eml.go index 051afe1f..ec4e794b 100644 --- a/internal/transfer/provider_eml.go +++ b/internal/transfer/provider_eml.go @@ -39,9 +39,13 @@ func (p *EMLProvider) ID() string { // Mailboxes returns all available folder names from root of EML files. // In case the same folder name is used more than once (for example root/a/foo // and root/b/foo), it's treated as the same folder. -func (p *EMLProvider) Mailboxes(includeEmpty, includeAllMail bool) ([]Mailbox, error) { +func (p *EMLProvider) Mailboxes(includeEmpty, includeAllMail bool) (mailboxes []Mailbox, err error) { + // Special case for exporting--we don't know the path before setup if finished. + if p.root == "" { + return + } + var folderNames []string - var err error if includeEmpty { folderNames, err = getFolderNames(p.root) } else { @@ -51,7 +55,6 @@ func (p *EMLProvider) Mailboxes(includeEmpty, includeAllMail bool) ([]Mailbox, e return nil, err } - mailboxes := []Mailbox{} for _, folderName := range folderNames { mailboxes = append(mailboxes, Mailbox{ ID: "", diff --git a/internal/transfer/provider_imap_errors.go b/internal/transfer/provider_imap_errors.go new file mode 100644 index 00000000..bbc2aefb --- /dev/null +++ b/internal/transfer/provider_imap_errors.go @@ -0,0 +1,66 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package transfer + +// imapError is base for all IMAP errors. +type imapError struct { + Message string + Err error +} + +func (e imapError) Error() string { + return e.Message + ": " + e.Err.Error() +} + +func (e imapError) Unwrap() error { + return e.Err +} + +func (e imapError) Cause() error { + return e.Err +} + +// ErrIMAPConnection is error representing connection issues. +type ErrIMAPConnection struct { + imapError +} + +func (e ErrIMAPConnection) Is(target error) bool { + _, ok := target.(*ErrIMAPConnection) + return ok +} + +// ErrIMAPAuth is error representing authentication issues. +type ErrIMAPAuth struct { + imapError +} + +func (e ErrIMAPAuth) Is(target error) bool { + _, ok := target.(*ErrIMAPAuth) + return ok +} + +// ErrIMAPAuthMethod is error representing wrong auth method. +type ErrIMAPAuthMethod struct { + imapError +} + +func (e ErrIMAPAuthMethod) Is(target error) bool { + _, ok := target.(*ErrIMAPAuthMethod) + return ok +} diff --git a/internal/transfer/provider_imap_utils.go b/internal/transfer/provider_imap_utils.go index 36b3cb2c..1edfeaff 100644 --- a/internal/transfer/provider_imap_utils.go +++ b/internal/transfer/provider_imap_utils.go @@ -137,7 +137,7 @@ func (p *IMAPProvider) auth() error { //nolint[funlen] log.Info("Connecting to server") if _, err := net.DialTimeout("tcp", p.addr, imapDialTimeout); err != nil { - return errors.Wrap(err, "failed to dial server") + return ErrIMAPConnection{imapError{Err: err, Message: "failed to dial server"}} } var client *imapClient.Client @@ -149,7 +149,7 @@ func (p *IMAPProvider) auth() error { //nolint[funlen] client, err = imapClient.DialTLS(p.addr, nil) } if err != nil { - return errors.Wrap(err, "failed to connect to server") + return ErrIMAPConnection{imapError{Err: err, Message: "failed to connect to server"}} } client.ErrorLog = &imapErrorLogger{logrus.WithField("pkg", "imap-client")} @@ -170,7 +170,7 @@ func (p *IMAPProvider) auth() error { //nolint[funlen] capability, err := p.client.Capability() log.WithField("capability", capability).WithError(err).Debug("Server capability") if err != nil { - return errors.Wrap(err, "failed to get capabilities") + return ErrIMAPConnection{imapError{Err: err, Message: "failed to get capabilities"}} } // SASL AUTH PLAIN @@ -178,7 +178,7 @@ func (p *IMAPProvider) auth() error { //nolint[funlen] log.Debug("Trying plain auth") authPlain := sasl.NewPlainClient("", p.username, p.password) if err = p.client.Authenticate(authPlain); err != nil { - return errors.Wrap(err, "plain auth failed") + return ErrIMAPAuth{imapError{Err: err, Message: "plain auth failed"}} } } @@ -186,12 +186,12 @@ func (p *IMAPProvider) auth() error { //nolint[funlen] if ok, _ := p.client.Support("IMAP4rev1"); p.client.State() == imap.NotAuthenticatedState && ok { log.Debug("Trying login") if err = p.client.Login(p.username, p.password); err != nil { - return errors.Wrap(err, "login failed") + return ErrIMAPAuth{imapError{Err: err, Message: "login failed"}} } } if p.client.State() == imap.NotAuthenticatedState { - return errors.New("unknown auth method") + return ErrIMAPAuthMethod{imapError{Err: err, Message: "unknown auth method"}} } log.Info("Logged in") diff --git a/internal/transfer/provider_pmapi.go b/internal/transfer/provider_pmapi.go index 61f708de..3baa5993 100644 --- a/internal/transfer/provider_pmapi.go +++ b/internal/transfer/provider_pmapi.go @@ -38,20 +38,24 @@ type PMAPIProvider struct { // NewPMAPIProvider returns new PMAPIProvider. func NewPMAPIProvider(clientManager ClientManager, userID, addressID string) (*PMAPIProvider, error) { - keyRing, err := clientManager.GetClient(userID).KeyRingForAddressID(addressID) - if err != nil { - return nil, errors.Wrap(err, "failed to get key ring") - } - - return &PMAPIProvider{ + provider := &PMAPIProvider{ clientManager: clientManager, userID: userID, addressID: addressID, - keyRing: keyRing, importMsgReqMap: map[string]*pmapi.ImportMsgReq{}, importMsgReqSize: 0, - }, nil + } + + if addressID != "" { + keyRing, err := clientManager.GetClient(userID).KeyRingForAddressID(addressID) + if err != nil { + return nil, errors.Wrap(err, "failed to get key ring") + } + provider.keyRing = keyRing + } + + return provider, nil } func (p *PMAPIProvider) client() pmapi.Client { @@ -86,7 +90,14 @@ func (p *PMAPIProvider) Mailboxes(includeEmpty, includeAllMail bool) ([]Mailbox, } } - mailboxes := getSystemMailboxes(includeAllMail) + mailboxes := []Mailbox{} + for _, mailbox := range getSystemMailboxes(includeAllMail) { + if !includeEmpty && emptyLabelsMap[mailbox.ID] { + continue + } + + mailboxes = append(mailboxes, mailbox) + } for _, label := range sortedLabels { if !includeEmpty && emptyLabelsMap[label.ID] { continue diff --git a/internal/transfer/provider_pmapi_source.go b/internal/transfer/provider_pmapi_source.go index 6c383812..9a9c3e6f 100644 --- a/internal/transfer/provider_pmapi_source.go +++ b/internal/transfer/provider_pmapi_source.go @@ -86,14 +86,15 @@ func (p *PMAPIProvider) transferTo(rule *Rule, progress *Progress, ch chan<- Mes progress.callWrap(func() error { desc := false pmapiMessages, count, err := p.listMessages(&pmapi.MessagesFilter{ - LabelID: rule.SourceMailbox.ID, - Begin: rule.FromTime, - End: rule.ToTime, - BeginID: nextID, - PageSize: pmapiListPageSize, - Page: 0, - Sort: "ID", - Desc: &desc, + AddressID: p.addressID, + LabelID: rule.SourceMailbox.ID, + Begin: rule.FromTime, + End: rule.ToTime, + BeginID: nextID, + PageSize: pmapiListPageSize, + Page: 0, + Sort: "ID", + Desc: &desc, }) if err != nil { return err diff --git a/internal/transfer/rules.go b/internal/transfer/rules.go index 2bf03915..7cd067b2 100644 --- a/internal/transfer/rules.go +++ b/internal/transfer/rules.go @@ -22,6 +22,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "time" @@ -42,6 +43,11 @@ type transferRules struct { // E.g., every message will be imported into this mailbox. globalMailbox *Mailbox + // globalFromTime and globalToTime is applied to every rule right + // before the transfer (propagateGlobalTime has to be called). + globalFromTime int64 + globalToTime int64 + // skipEncryptedMessages determines whether message which cannot // be decrypted should be exported or skipped. skipEncryptedMessages bool @@ -81,10 +87,18 @@ func (r *transferRules) setGlobalMailbox(mailbox *Mailbox) { } func (r *transferRules) setGlobalTimeLimit(fromTime, toTime int64) { + r.globalFromTime = fromTime + r.globalToTime = toTime +} + +func (r *transferRules) propagateGlobalTime() { + if r.globalFromTime == 0 && r.globalToTime == 0 { + return + } for _, rule := range r.rules { if !rule.HasTimeLimit() { - rule.FromTime = fromTime - rule.ToTime = toTime + rule.FromTime = r.globalFromTime + rule.ToTime = r.globalToTime } } } @@ -122,8 +136,9 @@ func (r *transferRules) setDefaultRules(sourceMailboxes []Mailbox, targetMailbox } targetMailboxes := sourceMailbox.findMatchingMailboxes(targetMailboxes) - if len(targetMailboxes) == 0 { - targetMailboxes = defaultCallback(sourceMailbox) + + if !containsExclusive(targetMailboxes) { + targetMailboxes = append(targetMailboxes, defaultCallback(sourceMailbox)...) } active := true @@ -147,10 +162,14 @@ func (r *transferRules) setDefaultRules(sourceMailboxes []Mailbox, targetMailbox } } - for _, rule := range r.rules { - if !rule.Active { - continue - } + // There is no point showing rule which has no action (i.e., source mailbox + // is not available). + // A good reason to keep all rules and only deactivate them would be for + // multiple imports from different sources with the same or similar enough + // mailbox setup to reuse configuration. That is very minor feature which + // can be implemented in more reasonable way by allowing users to save and + // load configurations. + for key, rule := range r.rules { found := false for _, sourceMailbox := range sourceMailboxes { if sourceMailbox.Name == rule.SourceMailbox.Name { @@ -158,7 +177,7 @@ func (r *transferRules) setDefaultRules(sourceMailboxes []Mailbox, targetMailbox } } if !found { - rule.Active = false + delete(r.rules, key) } } @@ -216,6 +235,7 @@ func (r *transferRules) getRules() []*Rule { for _, rule := range r.rules { rules = append(rules, rule) } + sort.Sort(byRuleOrder(rules)) return rules } @@ -288,3 +308,59 @@ func (r *Rule) TargetMailboxNames() (names []string) { } return } + +// byRuleOrder implements sort.Interface. Sort order: +// * System folders first (as defined in getSystemMailboxes). +// * Custom folders by name. +// * Custom labels by name. +type byRuleOrder []*Rule + +func (a byRuleOrder) Len() int { + return len(a) +} + +func (a byRuleOrder) Swap(i, j int) { + a[i], a[j] = a[j], a[i] +} + +func (a byRuleOrder) Less(i, j int) bool { + if a[i].SourceMailbox.IsExclusive && !a[j].SourceMailbox.IsExclusive { + return true + } + if !a[i].SourceMailbox.IsExclusive && a[j].SourceMailbox.IsExclusive { + return false + } + + iSystemIndex := -1 + jSystemIndex := -1 + for index, systemFolders := range getSystemMailboxes(true) { + if a[i].SourceMailbox.Name == systemFolders.Name { + iSystemIndex = index + } + if a[j].SourceMailbox.Name == systemFolders.Name { + jSystemIndex = index + } + } + if iSystemIndex != -1 && jSystemIndex == -1 { + return true + } + if iSystemIndex == -1 && jSystemIndex != -1 { + return false + } + if iSystemIndex != -1 && jSystemIndex != -1 { + return iSystemIndex < jSystemIndex + } + + return a[i].SourceMailbox.Name < a[j].SourceMailbox.Name +} + +// containsExclusive returns true if there is at least one exclusive mailbox. +func containsExclusive(mailboxes []Mailbox) bool { + for _, m := range mailboxes { + if m.IsExclusive { + return true + } + } + + return false +} diff --git a/internal/transfer/rules_test.go b/internal/transfer/rules_test.go index f887a8ac..a1d93582 100644 --- a/internal/transfer/rules_test.go +++ b/internal/transfer/rules_test.go @@ -86,6 +86,7 @@ func TestSetGlobalTimeLimit(t *testing.T) { r.NoError(t, rules.setRule(mailboxB, []Mailbox{}, 0, 0)) rules.setGlobalTimeLimit(30, 40) + rules.propagateGlobalTime() r.Equal(t, map[string]*Rule{ mailboxA.Hash(): {Active: true, SourceMailbox: mailboxA, TargetMailboxes: []Mailbox{}, FromTime: 10, ToTime: 20}, @@ -154,7 +155,6 @@ func TestSetDefaultRulesDeactivateMissing(t *testing.T) { r.Equal(t, map[string]*Rule{ mailboxA.Hash(): {Active: true, SourceMailbox: mailboxA, TargetMailboxes: []Mailbox{mailboxB}, FromTime: 0, ToTime: 0}, - mailboxB.Hash(): {Active: false, SourceMailbox: mailboxB, TargetMailboxes: []Mailbox{mailboxB}, FromTime: 0, ToTime: 0}, }, rules.rules) } @@ -208,3 +208,40 @@ func generateTimeRule(from, to int64) Rule { ToTime: to, } } + +func TestOrderRules(t *testing.T) { + wantMailboxOrder := []Mailbox{ + {Name: "Inbox", IsExclusive: true}, + {Name: "Drafts", IsExclusive: true}, + {Name: "Sent", IsExclusive: true}, + {Name: "Starred", IsExclusive: true}, + {Name: "Archive", IsExclusive: true}, + {Name: "Spam", IsExclusive: true}, + {Name: "All Mail", IsExclusive: true}, + {Name: "Folder A", IsExclusive: true}, + {Name: "Folder B", IsExclusive: true}, + {Name: "Folder C", IsExclusive: true}, + {Name: "Label A", IsExclusive: false}, + {Name: "Label B", IsExclusive: false}, + {Name: "Label C", IsExclusive: false}, + } + wantMailboxNames := []string{} + + rules := map[string]*Rule{} + for _, mailbox := range wantMailboxOrder { + wantMailboxNames = append(wantMailboxNames, mailbox.Name) + rules[mailbox.Hash()] = &Rule{ + SourceMailbox: mailbox, + } + } + transferRules := transferRules{ + rules: rules, + } + + gotMailboxNames := []string{} + for _, rule := range transferRules.getRules() { + gotMailboxNames = append(gotMailboxNames, rule.SourceMailbox.Name) + } + + r.Equal(t, wantMailboxNames, gotMailboxNames) +} diff --git a/internal/transfer/transfer.go b/internal/transfer/transfer.go index 2fda8f7e..566dfcb1 100644 --- a/internal/transfer/transfer.go +++ b/internal/transfer/transfer.go @@ -31,12 +31,14 @@ var log = logrus.WithField("pkg", "transfer") //nolint[gochecknoglobals] // Transfer is facade on top of import rules, progress manager and source // and target providers. This is the main object which should be used. type Transfer struct { - panicHandler PanicHandler - id string - dir string - rules transferRules - source SourceProvider - target TargetProvider + panicHandler PanicHandler + id string + dir string + rules transferRules + source SourceProvider + target TargetProvider + sourceMboxCache []Mailbox + targetMboxCache []Mailbox } // New creates Transfer for specific source and target. Usage: @@ -127,23 +129,33 @@ func (t *Transfer) GetRules() []*Rule { } // SourceMailboxes returns mailboxes available at source side. -func (t *Transfer) SourceMailboxes() ([]Mailbox, error) { - return t.source.Mailboxes(false, true) +func (t *Transfer) SourceMailboxes() (m []Mailbox, err error) { + if t.sourceMboxCache == nil { + t.sourceMboxCache, err = t.source.Mailboxes(false, true) + } + return t.sourceMboxCache, err } // TargetMailboxes returns mailboxes available at target side. -func (t *Transfer) TargetMailboxes() ([]Mailbox, error) { - return t.target.Mailboxes(true, false) +func (t *Transfer) TargetMailboxes() (m []Mailbox, err error) { + if t.targetMboxCache == nil { + t.targetMboxCache, err = t.target.Mailboxes(true, false) + } + return t.targetMboxCache, err } // CreateTargetMailbox creates mailbox in target provider. func (t *Transfer) CreateTargetMailbox(mailbox Mailbox) (Mailbox, error) { + t.targetMboxCache = nil + return t.target.CreateMailbox(mailbox) } // ChangeTarget changes the target. It is safe to change target for export, // must not be changed for import. Do not set after you started transfer. func (t *Transfer) ChangeTarget(target TargetProvider) { + t.targetMboxCache = nil + t.target = target } @@ -151,6 +163,7 @@ func (t *Transfer) ChangeTarget(target TargetProvider) { func (t *Transfer) Start() *Progress { log.Debug("Transfer started") t.rules.save() + t.rules.propagateGlobalTime() log := log.WithField("id", t.id) reportFile := newFileReport(t.dir, t.id) diff --git a/internal/users/mocks/mocks.go b/internal/users/mocks/mocks.go index dd087706..524d3273 100644 --- a/internal/users/mocks/mocks.go +++ b/internal/users/mocks/mocks.go @@ -5,12 +5,11 @@ package mocks import ( - reflect "reflect" - store "github.com/ProtonMail/proton-bridge/internal/store" credentials "github.com/ProtonMail/proton-bridge/internal/users/credentials" pmapi "github.com/ProtonMail/proton-bridge/pkg/pmapi" gomock "github.com/golang/mock/gomock" + reflect "reflect" ) // MockConfiger is a mock of Configer interface diff --git a/pkg/pmapi/bugs.go b/pkg/pmapi/bugs.go index b50b121c..ea792d6d 100644 --- a/pkg/pmapi/bugs.go +++ b/pkg/pmapi/bugs.go @@ -173,26 +173,6 @@ func (c *client) Report(rep ReportReq) (err error) { return res.Err() } -// ReportBug is old. Use Report instead. -func (c *client) ReportBug(os, osVersion, title, description, username, email string) (err error) { - return c.ReportBugWithEmailClient(os, osVersion, title, description, username, email, "") -} - -// ReportBugWithEmailClient is old. Use Report instead. -func (c *client) ReportBugWithEmailClient(os, osVersion, title, description, username, email, emailClient string) (err error) { - bugReq := ReportReq{ - OS: os, - OSVersion: osVersion, - Browser: emailClient, - Title: title, - Description: description, - Username: username, - Email: email, - } - - return c.Report(bugReq) -} - // ReportCrash is old. Use sentry instead. func (c *client) ReportCrash(stacktrace string) (err error) { crashReq := ReportReq{ diff --git a/pkg/pmapi/bugs_test.go b/pkg/pmapi/bugs_test.go index c3336c20..13792000 100644 --- a/pkg/pmapi/bugs_test.go +++ b/pkg/pmapi/bugs_test.go @@ -27,19 +27,7 @@ import ( "testing" ) -var testBugsReportReq = ReportReq{ - OS: "Mac OSX", - OSVersion: "10.11.6", - Client: "demoapp", - ClientVersion: "GoPMAPI_1.0.14", - ClientType: 1, - Title: "Big Bug", - Description: "Cannot fetch new messages", - Username: "apple", - Email: "apple@gmail.com", -} - -var testBugsReportReqWithEmailClient = ReportReq{ +var testBugReportReq = ReportReq{ OS: "Mac OSX", OSVersion: "10.11.6", Browser: "AppleMail", @@ -67,31 +55,6 @@ const testBugsBody = `{ const testAttachmentJSONZipped = "PK\x03\x04\x14\x00\b\x00\b\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\b\x00\x00\x00last.log\\Rَ\xaaH\x00}ﯨ\xf8r\x1f\xeeܖED;\xe9\ap\x03\x11\x11\x97\x0e8\x99L\xb0(\xa1\xa0\x16\x85b\x91I\xff\xfbD{\x99\xc9}\xab:K\x9d\xa4\xce\xf9\xe7\t\x00\x00z\xf6\xb4\xf7\x02z\xb7a\xe5\xd8\x04*V̭\x8d\xd1lvE}\xd6\xe3\x80\x1f\xd7nX\x9bI[\xa6\xe1a=\xd4a\xa8M\x97\xd9J\xf1F\xeb\x105U\xbd\xb0`XO\xce\xf1hu\x99q\xc3\xfe{\x11ߨ'-\v\x89Z\xa4\x9c5\xaf\xaf\xbd?>R\xd6\x11E\xf7\x1cX\xf0JpF#L\x9eE+\xbe\xe8\x1d\xee\ued2e\u007f\xde]\u06dd\xedo\x97\x87E\xa0V\xf4/$\xc2\xecK\xed\xa0\xdb&\x829\x12\xe5\x9do\xa0\xe9\x1a\xd2\x19\x1e\xf5`\x95гb\xf8\x89\x81\xb7\xa5G\x18\x95\xf3\x9d9\xe8\x93B\x17!\x1a^\xccr\xbb`\xb2\xb4\xb86\x87\xb4h\x0e\xda\xc6u<+\x9e$̓\x95\xccSo\xea\xa4\xdbH!\xe9g\x8b\xd4\b\xb3hܬ\xa6Wk\x14He\xae\x8aPU\xaa\xc1\xee$\xfbH\xb3\xab.I\f<\x89\x06q\xe3-3-\x99\xcdݽ\xe5v\x99\xedn\xac\xadn\xe8Rp=\xb4nJ\xed\xd5\r\x8d\xde\x06Ζ\xf6\xb3\x01\x94\xcb\xf6\xd4\x19r\xe1\xaa$4+\xeaW\xa6F\xfa0\x97\x9cD\f\x8e\xd7\xd6z\v,G\xf3e2\xd4\xe6V\xba\v\xb6\xd9\xe8\xca*\x16\x95V\xa4J\xfbp\xddmF\x8c\x9a\xc6\xc8Č-\xdb\v\xf6\xf5\xf9\x02*\x15e\x874\xc9\xe7\"\xa3\x1an\xabq}ˊq\x957\xd3\xfd\xa91\x82\xe0Lß\\\x17\x8e\x9e_\xed`\t\xe9~5̕\x03\x9a\f\xddN6\xa2\xc4\x17\xdb\xc9V\x1c~\x9e\xea\xbe\xda-xv\xed\x8b\xe2\xc8DŽS\x95E6\xf2\xc3H\x1d:HPx\xc9\x14\xbfɒ\xff\xea\xb4P\x14\xa3\xe2\xfe\xfd\x1f+z\x80\x903\x81\x98\xf8\x15\xa3\x12\x16\xf8\"0g\xf7~B^\xfd \x040T\xa3\x02\x9c\x10\xc1\xa8F\xa0I#\xf1\xa3\x04\x98\x01\x91\xe2\x12\xdc;\x06gL\xd0g\xc0\xe3\xbd\xf6\xd7}&\xa8轀?\xbfяy`X\xf0\x92\x9f\x05\xf0*A8ρ\xac=K\xff\xf3\xfe\xa6Z\xe1\x1a\x017\xc2\x04\f\x94g\xa9\xf7-\xfb\xebqz\u007fz\u007f\xfa7\x00\x00\xff\xffPK\a\b\xf5\\\v\xe5I\x02\x00\x00\r\x03\x00\x00PK\x01\x02\x14\x00\x14\x00\b\x00\b\x00\x00\x00\x00\x00\xf5\\\v\xe5I\x02\x00\x00\r\x03\x00\x00\b\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00last.logPK\x05\x06\x00\x00\x00\x00\x01\x00\x01\x006\x00\x00\x00\u007f\x02\x00\x00\x00\x00" //nolint[misspell] -func TestClient_BugReport(t *testing.T) { - s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - Ok(t, checkMethodAndPath(r, "POST", "/reports/bug")) - Ok(t, isAuthReq(r, testUID, testAccessToken)) - - var bugsReportReq ReportReq - Ok(t, json.NewDecoder(r.Body).Decode(&bugsReportReq)) - Equals(t, testBugsReportReq, bugsReportReq) - - fmt.Fprint(w, testBugsBody) - })) - defer s.Close() - c.uid = testUID - c.accessToken = testAccessToken - - Ok(t, c.ReportBug( - testBugsReportReq.OS, - testBugsReportReq.OSVersion, - testBugsReportReq.Title, - testBugsReportReq.Description, - testBugsReportReq.Username, - testBugsReportReq.Email, - )) -} - func TestClient_BugReportWithAttachment(t *testing.T) { s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { Ok(t, checkMethodAndPath(r, "POST", "/reports/bug")) @@ -100,15 +63,15 @@ func TestClient_BugReportWithAttachment(t *testing.T) { Ok(t, r.ParseMultipartForm(10*1024)) for field, expected := range map[string]string{ - "OS": testBugsReportReq.OS, - "OSVersion": testBugsReportReq.OSVersion, - "Client": testBugsReportReq.Client, - "ClientVersion": testBugsReportReq.ClientVersion, - "ClientType": fmt.Sprintf("%d", testBugsReportReq.ClientType), - "Title": testBugsReportReq.Title, - "Description": testBugsReportReq.Description, - "Username": testBugsReportReq.Username, - "Email": testBugsReportReq.Email, + "OS": testBugReportReq.OS, + "OSVersion": testBugReportReq.OSVersion, + "Client": testBugReportReq.Client, + "ClientVersion": testBugReportReq.ClientVersion, + "ClientType": fmt.Sprintf("%d", testBugReportReq.ClientType), + "Title": testBugReportReq.Title, + "Description": testBugReportReq.Description, + "Username": testBugReportReq.Username, + "Email": testBugReportReq.Email, } { if r.PostFormValue(field) != expected { t.Errorf("Field %q has %q but expected %q", field, r.PostFormValue(field), expected) @@ -129,20 +92,20 @@ func TestClient_BugReportWithAttachment(t *testing.T) { c.uid = testUID c.accessToken = testAccessToken - rep := testBugsReportReq + rep := testBugReportReq rep.AddAttachment("log", "last.log", strings.NewReader(testAttachmentJSON)) Ok(t, c.Report(rep)) } -func TestClient_BugReportWithEmailClient(t *testing.T) { +func TestClient_BugReport(t *testing.T) { s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { Ok(t, checkMethodAndPath(r, "POST", "/reports/bug")) Ok(t, isAuthReq(r, testUID, testAccessToken)) var bugsReportReq ReportReq Ok(t, json.NewDecoder(r.Body).Decode(&bugsReportReq)) - Equals(t, testBugsReportReqWithEmailClient, bugsReportReq) + Equals(t, testBugReportReq, bugsReportReq) fmt.Fprint(w, testBugsBody) })) @@ -150,15 +113,17 @@ func TestClient_BugReportWithEmailClient(t *testing.T) { c.uid = testUID c.accessToken = testAccessToken - Ok(t, c.ReportBugWithEmailClient( - testBugsReportReqWithEmailClient.OS, - testBugsReportReqWithEmailClient.OSVersion, - testBugsReportReqWithEmailClient.Title, - testBugsReportReqWithEmailClient.Description, - testBugsReportReqWithEmailClient.Username, - testBugsReportReqWithEmailClient.Email, - testBugsReportReqWithEmailClient.Browser, - )) + r := ReportReq{ + OS: testBugReportReq.OS, + OSVersion: testBugReportReq.OSVersion, + Browser: testBugReportReq.Browser, + Title: testBugReportReq.Title, + Description: testBugReportReq.Description, + Username: testBugReportReq.Username, + Email: testBugReportReq.Email, + } + + Ok(t, c.Report(r)) } func TestClient_BugsCrash(t *testing.T) { diff --git a/pkg/pmapi/client_types.go b/pkg/pmapi/client_types.go index 538d82cb..168c9936 100644 --- a/pkg/pmapi/client_types.go +++ b/pkg/pmapi/client_types.go @@ -67,7 +67,7 @@ type Client interface { DeleteLabel(labelID string) error EmptyFolder(labelID string, addressID string) error - ReportBugWithEmailClient(os, osVersion, title, description, username, email, emailClient string) error + Report(report ReportReq) error SendSimpleMetric(category, action, label string) error GetMailSettings() (MailSettings, error) diff --git a/pkg/pmapi/mocks/mocks.go b/pkg/pmapi/mocks/mocks.go index 11399d72..6c30a7cc 100644 --- a/pkg/pmapi/mocks/mocks.go +++ b/pkg/pmapi/mocks/mocks.go @@ -5,12 +5,11 @@ package mocks import ( - io "io" - reflect "reflect" - crypto "github.com/ProtonMail/gopenpgp/v2/crypto" pmapi "github.com/ProtonMail/proton-bridge/pkg/pmapi" gomock "github.com/golang/mock/gomock" + io "io" + reflect "reflect" ) // MockClient is a mock of Client interface @@ -601,18 +600,18 @@ func (mr *MockClientMockRecorder) ReorderAddresses(arg0 interface{}) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReorderAddresses", reflect.TypeOf((*MockClient)(nil).ReorderAddresses), arg0) } -// ReportBugWithEmailClient mocks base method -func (m *MockClient) ReportBugWithEmailClient(arg0, arg1, arg2, arg3, arg4, arg5, arg6 string) error { +// Report mocks base method +func (m *MockClient) Report(arg0 pmapi.ReportReq) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ReportBugWithEmailClient", arg0, arg1, arg2, arg3, arg4, arg5, arg6) + ret := m.ctrl.Call(m, "Report", arg0) ret0, _ := ret[0].(error) return ret0 } -// ReportBugWithEmailClient indicates an expected call of ReportBugWithEmailClient -func (mr *MockClientMockRecorder) ReportBugWithEmailClient(arg0, arg1, arg2, arg3, arg4, arg5, arg6 interface{}) *gomock.Call { +// Report indicates an expected call of Report +func (mr *MockClientMockRecorder) Report(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReportBugWithEmailClient", reflect.TypeOf((*MockClient)(nil).ReportBugWithEmailClient), arg0, arg1, arg2, arg3, arg4, arg5, arg6) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Report", reflect.TypeOf((*MockClient)(nil).Report), arg0) } // SendMessage mocks base method diff --git a/test/fakeapi/reports.go b/test/fakeapi/reports.go index 7b8175eb..f0604eb4 100644 --- a/test/fakeapi/reports.go +++ b/test/fakeapi/reports.go @@ -23,16 +23,8 @@ import ( "github.com/ProtonMail/proton-bridge/pkg/pmapi" ) -func (api *FakePMAPI) ReportBugWithEmailClient(os, osVersion, title, description, username, email, emailClient string) error { - return api.checkInternetAndRecordCall(POST, "/reports/bug", &pmapi.ReportReq{ - OS: os, - OSVersion: osVersion, - Title: title, - Description: description, - Username: username, - Email: email, - Browser: emailClient, - }) +func (api *FakePMAPI) Report(report pmapi.ReportReq) error { + return api.checkInternetAndRecordCall(POST, "/reports/bug", report) } func (api *FakePMAPI) SendSimpleMetric(category, action, label string) error { From 4f0af0fb0267961400f10f4d287c571c7067e985 Mon Sep 17 00:00:00 2001 From: Michal Horejsek Date: Fri, 31 Jul 2020 12:08:40 +0200 Subject: [PATCH 05/22] Import/Export metrics --- internal/importexport/importexport.go | 8 ++-- internal/importexport/metrics.go | 64 +++++++++++++++++++++++++++ internal/metrics/metrics.go | 26 +++++++++++ internal/transfer/transfer.go | 17 ++++++- internal/transfer/types.go | 8 ++++ 5 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 internal/importexport/metrics.go diff --git a/internal/importexport/importexport.go b/internal/importexport/importexport.go index 761c2992..0825fea9 100644 --- a/internal/importexport/importexport.go +++ b/internal/importexport/importexport.go @@ -120,7 +120,7 @@ func (ie *ImportExport) GetLocalImporter(address, path string) (*transfer.Transf if err != nil { return nil, err } - return transfer.New(ie.panicHandler, ie.config.GetTransferDir(), source, target) + return transfer.New(ie.panicHandler, newImportMetricsManager(ie), ie.config.GetTransferDir(), source, target) } // GetRemoteImporter returns transferrer from remote IMAP to ProtonMail account. @@ -133,7 +133,7 @@ func (ie *ImportExport) GetRemoteImporter(address, username, password, host, por if err != nil { return nil, err } - return transfer.New(ie.panicHandler, ie.config.GetTransferDir(), source, target) + return transfer.New(ie.panicHandler, newImportMetricsManager(ie), ie.config.GetTransferDir(), source, target) } // GetEMLExporter returns transferrer from ProtonMail account to local EML structure. @@ -143,7 +143,7 @@ func (ie *ImportExport) GetEMLExporter(address, path string) (*transfer.Transfer return nil, err } target := transfer.NewEMLProvider(path) - return transfer.New(ie.panicHandler, ie.config.GetTransferDir(), source, target) + return transfer.New(ie.panicHandler, newExportMetricsManager(ie), ie.config.GetTransferDir(), source, target) } // GetMBOXExporter returns transferrer from ProtonMail account to local MBOX structure. @@ -153,7 +153,7 @@ func (ie *ImportExport) GetMBOXExporter(address, path string) (*transfer.Transfe return nil, err } target := transfer.NewMBOXProvider(path) - return transfer.New(ie.panicHandler, ie.config.GetTransferDir(), source, target) + return transfer.New(ie.panicHandler, newExportMetricsManager(ie), ie.config.GetTransferDir(), source, target) } func (ie *ImportExport) getPMAPIProvider(address string) (*transfer.PMAPIProvider, error) { diff --git a/internal/importexport/metrics.go b/internal/importexport/metrics.go new file mode 100644 index 00000000..878c346a --- /dev/null +++ b/internal/importexport/metrics.go @@ -0,0 +1,64 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package importexport + +import ( + "strconv" + + "github.com/ProtonMail/proton-bridge/internal/metrics" +) + +type metricsManager struct { + ie *ImportExport + category metrics.Category +} + +func newImportMetricsManager(ie *ImportExport) *metricsManager { + return &metricsManager{ + ie: ie, + category: metrics.Import, + } +} + +func newExportMetricsManager(ie *ImportExport) *metricsManager { + return &metricsManager{ + ie: ie, + category: metrics.Export, + } +} + +func (m *metricsManager) Load(numberOfMailboxes int) { + label := strconv.Itoa(numberOfMailboxes) + m.ie.SendMetric(metrics.New(m.category, metrics.TransferLoad, metrics.Label(label))) +} + +func (m *metricsManager) Start() { + m.ie.SendMetric(metrics.New(m.category, metrics.TransferStart, metrics.NoLabel)) +} + +func (m *metricsManager) Complete() { + m.ie.SendMetric(metrics.New(m.category, metrics.TransferComplete, metrics.NoLabel)) +} + +func (m *metricsManager) Cancel() { + m.ie.SendMetric(metrics.New(m.category, metrics.TransferCancel, metrics.NoLabel)) +} + +func (m *metricsManager) Fail() { + m.ie.SendMetric(metrics.New(m.category, metrics.TransferFail, metrics.NoLabel)) +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index b18bf5ac..a14b1154 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -67,4 +67,30 @@ const ( Daily = Action("daily") ) +// Metrics related to import/export (transfer) process. +const ( + // Import is used to group import metrics. + Import = Category("import") + + // Export is used to group export metrics. + Export = Category("export") + + // TransferLoad signifies that the transfer load source. + // It can be IMAP or local files for import, or PM for export. + // With this will be reported also label with number of source mailboxes. + TransferLoad = Action("load") + + // TransferStart signifies started transfer. + TransferStart = Action("start") + + // TransferComplete signifies completed transfer without crash. + TransferComplete = Action("complete") + + // TransferCancel signifies cancelled transfer by an user. + TransferCancel = Action("cancel") + + // TransferFail signifies stopped transfer because of an fatal error. + TransferFail = Action("fail") +) + const NoLabel = Label("") diff --git a/internal/transfer/transfer.go b/internal/transfer/transfer.go index 566dfcb1..6d7ecf68 100644 --- a/internal/transfer/transfer.go +++ b/internal/transfer/transfer.go @@ -32,6 +32,7 @@ var log = logrus.WithField("pkg", "transfer") //nolint[gochecknoglobals] // and target providers. This is the main object which should be used. type Transfer struct { panicHandler PanicHandler + metrics MetricsManager id string dir string rules transferRules @@ -46,11 +47,12 @@ type Transfer struct { // source := transfer.NewEMLProvider(...) // target := transfer.NewPMAPIProvider(...) // transfer.New(source, target, ...) -func New(panicHandler PanicHandler, transferDir string, source SourceProvider, target TargetProvider) (*Transfer, error) { +func New(panicHandler PanicHandler, metrics MetricsManager, transferDir string, source SourceProvider, target TargetProvider) (*Transfer, error) { transferID := fmt.Sprintf("%x", sha256.Sum256([]byte(source.ID()+"-"+target.ID()))) rules := loadRules(transferDir, transferID) transfer := &Transfer{ panicHandler: panicHandler, + metrics: metrics, id: transferID, dir: transferDir, rules: rules, @@ -60,6 +62,7 @@ func New(panicHandler PanicHandler, transferDir string, source SourceProvider, t if err := transfer.setDefaultRules(); err != nil { return nil, err } + metrics.Load(len(transfer.sourceMboxCache)) return transfer, nil } @@ -165,6 +168,8 @@ func (t *Transfer) Start() *Progress { t.rules.save() t.rules.propagateGlobalTime() + t.metrics.Start() + log := log.WithField("id", t.id) reportFile := newFileReport(t.dir, t.id) progress := newProgress(log, reportFile) @@ -184,6 +189,16 @@ func (t *Transfer) Start() *Progress { t.target.TransferFrom(t.rules, &progress, ch) progress.finish() + + if progress.isStopped { + if progress.fatalError != nil { + t.metrics.Fail() + } else { + t.metrics.Cancel() + } + } else { + t.metrics.Complete() + } }() return &progress diff --git a/internal/transfer/types.go b/internal/transfer/types.go index 9b12db49..c9dec426 100644 --- a/internal/transfer/types.go +++ b/internal/transfer/types.go @@ -25,6 +25,14 @@ type PanicHandler interface { HandlePanic() } +type MetricsManager interface { + Load(int) + Start() + Complete() + Cancel() + Fail() +} + type ClientManager interface { GetClient(userID string) pmapi.Client CheckConnection() error From 658ead9fb39b5074e8944f642ce06d5a399d3e6a Mon Sep 17 00:00:00 2001 From: Michal Horejsek Date: Wed, 12 Aug 2020 13:56:49 +0200 Subject: [PATCH 06/22] Import/Export final touches --- .gitlab-ci.yml | 62 ++++--------- Changelog.md | 56 +++--------- Makefile | 16 ++-- README.md | 12 ++- cmd/Desktop-Bridge/main.go | 10 +-- cmd/Import-Export/main.go | 33 +++---- doc/importexport.md | 20 ++--- doc/index.md | 4 +- internal/bridge/credits.go | 4 +- .../cmd/{memory_profile.go => profiles.go} | 15 +++- internal/cmd/version_file.go | 2 +- internal/frontend/cli-ie/account_utils.go | 2 +- internal/frontend/cli-ie/accounts.go | 2 +- internal/frontend/cli-ie/frontend.go | 18 ++-- internal/frontend/cli-ie/importexport.go | 2 +- internal/frontend/cli-ie/system.go | 14 ++- internal/frontend/cli-ie/updates.go | 6 +- internal/frontend/cli-ie/utils.go | 6 +- internal/frontend/cli/frontend.go | 2 +- internal/frontend/cli/system.go | 2 +- internal/frontend/cli/updates.go | 2 +- .../qml/ImportExportUI/DialogImport.qml | 2 +- .../qml/ImportExportUI/ImportDelegate.qml | 20 +++-- .../qml/ImportExportUI/ImportStructure.qml | 2 +- .../qml/ImportExportUI/MainWindow.qml | 10 ++- .../qml/ImportExportUI/SelectFolderMenu.qml | 1 - .../frontend/qml/ProtonUI/PopupMessage.qml | 3 +- internal/frontend/qml/tst_GuiIE.qml | 18 ++-- internal/frontend/qt-common/common.go | 10 --- internal/frontend/qt-ie/export.go | 63 +++---------- internal/frontend/qt-ie/folder_functions.go | 18 ++-- internal/frontend/qt-ie/frontend.go | 17 ++-- internal/frontend/qt-ie/import.go | 2 + internal/frontend/qt-ie/mbox.go | 33 ++++--- .../{qt-common => qt-ie}/path_status.go | 4 +- internal/frontend/qt-ie/transfer_rules.go | 51 ++++++++--- internal/frontend/qt-ie/ui.go | 3 +- internal/frontend/qt/frontend.go | 6 +- internal/frontend/types/types.go | 8 +- internal/importexport/importexport.go | 12 +-- internal/importexport/store_factory.go | 2 +- internal/importexport/types.go | 1 + internal/metrics/metrics.go | 2 +- internal/transfer/mailbox.go | 65 +++++++++----- internal/transfer/mailbox_test.go | 7 ++ internal/transfer/progress.go | 85 +++++++++++------- internal/transfer/progress_test.go | 8 +- internal/transfer/provider_eml_source.go | 5 ++ internal/transfer/provider_eml_target.go | 3 +- internal/transfer/provider_imap.go | 6 +- internal/transfer/provider_imap_source.go | 13 ++- internal/transfer/provider_mbox_source.go | 5 ++ internal/transfer/provider_pmapi_source.go | 38 ++++---- internal/transfer/provider_pmapi_target.go | 5 ++ internal/transfer/rules.go | 4 +- internal/transfer/rules_test.go | 2 +- internal/transfer/transfer.go | 20 +++-- {pkg => internal}/updates/bridge_pubkey.gpg | 0 {pkg => internal}/updates/compare_versions.go | 0 .../updates/compare_versions_test.go | 0 {pkg => internal}/updates/downloader.go | 0 {pkg => internal}/updates/progress.go | 0 {pkg => internal}/updates/signature.go | 0 {pkg => internal}/updates/sync.go | 0 {pkg => internal}/updates/sync_test.go | 0 {pkg => internal}/updates/tar.go | 0 .../testdata/current_version_linux.json | 0 .../testdata/current_version_linux.json.sig | Bin {pkg => internal}/updates/updates.go | 4 +- {pkg => internal}/updates/updates_beta.go | 0 {pkg => internal}/updates/updates_qa.go | 0 {pkg => internal}/updates/updates_test.go | 0 {pkg => internal}/updates/version_info.go | 0 internal/users/credentials/credentials.go | 2 +- internal/users/users.go | 11 ++- pkg/config/config.go | 6 +- pkg/message/build.go | 10 +-- pkg/pmapi/addresses.go | 9 ++ pkg/pmapi/clientmanager.go | 5 +- test/context/context.go | 2 +- test/context/importexport.go | 6 +- .../ie/transfer/import_export.feature | 2 +- 82 files changed, 451 insertions(+), 450 deletions(-) rename internal/cmd/{memory_profile.go => profiles.go} (75%) rename internal/frontend/{qt-common => qt-ie}/path_status.go (98%) rename {pkg => internal}/updates/bridge_pubkey.gpg (100%) rename {pkg => internal}/updates/compare_versions.go (100%) rename {pkg => internal}/updates/compare_versions_test.go (100%) rename {pkg => internal}/updates/downloader.go (100%) rename {pkg => internal}/updates/progress.go (100%) rename {pkg => internal}/updates/signature.go (100%) rename {pkg => internal}/updates/sync.go (100%) rename {pkg => internal}/updates/sync_test.go (100%) rename {pkg => internal}/updates/tar.go (100%) rename {pkg => internal}/updates/testdata/current_version_linux.json (100%) rename {pkg => internal}/updates/testdata/current_version_linux.json.sig (100%) rename {pkg => internal}/updates/updates.go (98%) rename {pkg => internal}/updates/updates_beta.go (100%) rename {pkg => internal}/updates/updates_qa.go (100%) rename {pkg => internal}/updates/updates_test.go (100%) rename {pkg => internal}/updates/version_info.go (100%) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index bd5c1c5d..5a52b7b0 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -75,34 +75,33 @@ dependency-updates: # Stage: BUILD -build-linux: +.build-base: stage: build only: - branches script: - make build + artifacts: + expire_in: 2 week + +build-linux: + extends: .build-base artifacts: name: "bridge-linux-$CI_COMMIT_SHORT_SHA" paths: - bridge_*.tgz - expire_in: 2 week build-ie-linux: - stage: build - only: - - branches + extends: .build-base script: - make build-ie artifacts: name: "ie-linux-$CI_COMMIT_SHORT_SHA" paths: - ie_*.tgz - expire_in: 2 week -build-darwin: - stage: build - only: - - branches +.build-darwin-base: + extends: .build-base before_script: - eval $(ssh-agent -s) - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null @@ -117,47 +116,32 @@ build-darwin: cache: {} tags: - macOS - script: - - make build + +build-darwin: + extends: .build-darwin-base artifacts: name: "bridge-darwin-$CI_COMMIT_SHORT_SHA" paths: - bridge_*.tgz - expire_in: 2 week build-ie-darwin: - stage: build - only: - - branches - before_script: - - eval $(ssh-agent -s) - - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null - - export PATH=/usr/local/bin:$PATH - - export PATH=/usr/local/opt/git/bin:$PATH - - export PATH=/usr/local/opt/make/libexec/gnubin:$PATH - - export PATH=/usr/local/opt/go@1.13/bin:$PATH - - export PATH=/usr/local/opt/gnu-sed/libexec/gnubin:$PATH - - export GOPATH=~/go - - export PATH=$GOPATH/bin:$PATH - cache: {} - tags: - - macOS-bridge + extends: .build-darwin-base script: - make build-ie artifacts: name: "ie-darwin-$CI_COMMIT_SHORT_SHA" paths: - ie_*.tgz - expire_in: 2 week -build-windows: - stage: build +.build-windows-base: + extends: .build-base services: - docker:dind - only: - - branches variables: DOCKER_HOST: tcp://docker:2375 + +build-windows: + extends: .build-windows-base script: # We need to install docker because qtdeploy builds for windows inside a docker container. # Docker will connect to the dockerd daemon provided by the runner service docker:dind at tcp://docker:2375. @@ -170,16 +154,9 @@ build-windows: name: "bridge-windows-$CI_COMMIT_SHORT_SHA" paths: - bridge_*.tgz - expire_in: 2 week build-ie-windows: - stage: build - services: - - docker:dind - only: - - branches - variables: - DOCKER_HOST: tcp://docker:2375 + extends: .build-windows-base script: # We need to install docker because qtdeploy builds for windows inside a docker container. # Docker will connect to the dockerd daemon provided by the runner service docker:dind at tcp://docker:2375. @@ -192,7 +169,6 @@ build-ie-windows: name: "ie-windows-$CI_COMMIT_SHORT_SHA" paths: - ie_*.tgz - expire_in: 2 week # Stage: MIRROR diff --git a/Changelog.md b/Changelog.md index 7e6bf64b..5fd820b2 100644 --- a/Changelog.md +++ b/Changelog.md @@ -35,6 +35,18 @@ Changelog [format](http://keepachangelog.com/en/1.0.0/) * Set first-start to false in bridge, not in frontend. * GODT-400 Refactor sendingInfo. +* GODT-380 Adding IE GUI to Bridge repo and building + * BR: extend functionality of PopupDialog + * BR: makefile APP_VERSION instead of BRIDGE_VERSION + * BR: use common logs function for Qt + * BR: change `go.progressDescription` to `string` + * IE: Rounded button has fa-icon + * IE: `Upgrade` → `Update` + * IE: Moving `AccountModel` to `qt-common` + * IE: Added `ReportBug` to `internal/importexport` + * IE: Added event watch in GUI + * IE: Removed `onLoginFinished` + * Structure for transfer rules in QML ### Fixed * GODT-454 Fix send on closed channel when receiving unencrypted send confirmation from GUI. @@ -47,8 +59,6 @@ Changelog [format](http://keepachangelog.com/en/1.0.0/) * GODT-554 Detect and notify about "bad certificate" IMAP TLS error. * IMAP mailbox info update when new mailbox is created. * GODT-72 Use ISO-8859-1 encoding if charset is not specified and it isn't UTF-8. -* Structure for transfer rules in QML -* GODT-360 Detect charset embedded in html/xml. ### Changed * GODT-360 Detect charset embedded in html/xml. @@ -62,48 +72,6 @@ Changelog [format](http://keepachangelog.com/en/1.0.0/) * GODT-280 Migrate to gopenpgp v2. * `Unlock()` call on pmapi-client unlocks both User keys and Address keys. * Salt is available via `AuthSalt()` method. -* GODT-394 Don't check SMTP message send time in integration tests. -* GODT-380 Adding IE GUI to Bridge repo -* GODT-380 Adding IE GUI to Bridge repo and building - * BR: extend functionality of PopupDialog - * BR: makefile APP_VERSION instead of BRIDGE_VERSION - * BR: use common logs function for Qt - * BR: change `go.progressDescription` to `string` - * IE: Rounded button has fa-icon - * IE: `Upgrade` → `Update` - * IE: Moving `AccountModel` to `qt-common` - * IE: Added `ReportBug` to `internal/importexport` - * IE: Added event watch in GUI - * IE: Removed `onLoginFinished` -* GODT-388 support for both bridge and import/export credentials by package users -* GODT-387 store factory to make store optional -* GODT-386 renamed bridge to general users and keep bridge only for bridge stuff -* GODT-308 better user error message when request is canceled -* GODT-312 validate recipient emails in send before asking for their public keys - -### Fixed -* GODT-356 Fix crash when removing account while mail client is fetching messages (regression from GODT-204). -* GODT-390 Don't logout user if AuthRefresh fails because internet was off. -* GODT-358 Bad timeouts with Alternative Routing. -* GODT-363 Drafts are not deleted when already created on webapp. -* GODT-390 Don't logout user if AuthRefresh fails because internet was off. -* GODT-341 Fixed flaky unittest for Store synchronization cooldown. -* Crash when failing to match necessary html element. -* Crash in message.combineParts when copying nil slice. -* Handle double charset better by using local ParseMediaType instead of mime.ParseMediaType. -* Don't remove log dir. -* GODT-422 Fix element not found (avoid listing credentials, prefer getting). -* GODT-404 Don't keep connections to proxy servers alive if user disables DoH. -* Ensure DoH is used at startup to load users for the initial auth. -* Issue causing deadlock when reloading users keys due to double-locking of a mutex. - -## [v1.2.7] Donghai-hotfix - beta (2020-05-07) - -### Added -* IMAP mailbox info update when new mailbox is created. -* GODT-72 Use ISO-8859-1 encoding if charset is not specified and it isn't UTF-8. - -### Changed * GODT-308 Better user error message when request is canceled. * GODT-162 User Agent does not contain bridge version, only client in format `client name/client version (os)`. * GODT-258 Update go-imap to v1. diff --git a/Makefile b/Makefile index 56f40189..4407c70c 100644 --- a/Makefile +++ b/Makefile @@ -176,12 +176,10 @@ test: gofiles ./internal/smtp/... \ ./internal/store/... \ ./internal/transfer/... \ + ./internal/updates/... \ ./internal/users/... \ ./pkg/... -test-ie: - go test ./internal/transfer/... - bench: go test -run '^$$' -bench=. -memprofile bench_mem.pprof -cpuprofile bench_cpu.pprof ./internal/store go tool pprof -png -output bench_mem.png bench_mem.pprof @@ -228,7 +226,8 @@ gofiles: ./internal/bridge/credits.go ./internal/bridge/release_notes.go ./inter ## Run and debug -.PHONY: run run-ie run-qt run-ie-qt run-qt-cli run-nogui run-ie-nogui run-nogui-cli run-debug run-qml-preview run-ie-qml-preview clean-fronted-qt clean-fronted-qt-ie clean-fronted-qt-common clean +.PHONY: run run-qt run-qt-cli run-nogui run-nogui-cli run-debug run-qml-preview run-ie-qml-preview run-ie run-ie-qt run-ie-qt-cli run-ie-nogui run-ie-nogui-cli clean-vendor clean-frontend-qt clean-frontend-qt-ie clean-frontend-qt-common clean + VERBOSITY?=debug-client RUN_FLAGS:=-m -l=${VERBOSITY} @@ -249,27 +248,24 @@ run-debug: run-qml-preview: $(MAKE) -C internal/frontend/qt -f Makefile.local qmlpreview - run-ie-qml-preview: $(MAKE) -C internal/frontend/qt-ie -f Makefile.local qmlpreview run-ie: TARGET_CMD=Import-Export $(MAKE) run -run-ie-nogui: - TARGET_CMD=Import-Export $(MAKE) run-nogui run-ie-qt: TARGET_CMD=Import-Export $(MAKE) run-qt +run-ie-nogui: + TARGET_CMD=Import-Export $(MAKE) run-nogui + clean-frontend-qt: $(MAKE) -C internal/frontend/qt -f Makefile.local clean - clean-frontend-qt-ie: $(MAKE) -C internal/frontend/qt-ie -f Makefile.local clean - clean-frontend-qt-common: $(MAKE) -C internal/frontend/qt-common -f Makefile.local clean - clean-vendor: clean-frontend-qt clean-frontend-qt-ie clean-frontend-qt-common rm -rf ./vendor diff --git a/README.md b/README.md index f512e1d6..b9d90831 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,16 @@ background. More details [on the public website](https://protonmail.com/bridge). -## Description Import-Export -TODO +## Description Import-Export app +ProtonMail Import-Export app for importing and exporting messages. + +To transfer messages, firstly log in using your ProtonMail credentials. +For import, expand your account, and pick the address to which to import +messages from IMAP server or local EML or MBOX files. For export, pick +the whole account or only a specific address. Then, in both cases, +configure transfer rules (match source and target mailboxes, set time +range limits and so on) and hit start. Once the transfer is complete, +check the results. ## Keychain You need to have a keychain in order to run the ProtonMail Bridge. On Mac or diff --git a/cmd/Desktop-Bridge/main.go b/cmd/Desktop-Bridge/main.go index 0dc459b3..2377ebe9 100644 --- a/cmd/Desktop-Bridge/main.go +++ b/cmd/Desktop-Bridge/main.go @@ -48,12 +48,12 @@ import ( "github.com/ProtonMail/proton-bridge/internal/imap" "github.com/ProtonMail/proton-bridge/internal/preferences" "github.com/ProtonMail/proton-bridge/internal/smtp" + "github.com/ProtonMail/proton-bridge/internal/updates" "github.com/ProtonMail/proton-bridge/internal/users/credentials" "github.com/ProtonMail/proton-bridge/pkg/config" "github.com/ProtonMail/proton-bridge/pkg/constants" "github.com/ProtonMail/proton-bridge/pkg/listener" "github.com/ProtonMail/proton-bridge/pkg/pmapi" - "github.com/ProtonMail/proton-bridge/pkg/updates" "github.com/allan-simon/go-singleinstance" "github.com/sirupsen/logrus" "github.com/urfave/cli" @@ -168,13 +168,7 @@ func run(context *cli.Context) (contextError error) { // nolint[funlen] // In case user wants to do CPU or memory profiles... if doCPUProfile := context.GlobalBool("cpu-prof"); doCPUProfile { - f, err := os.Create("cpu.pprof") - if err != nil { - log.Fatal("Could not create CPU profile: ", err) - } - if err := pprof.StartCPUProfile(f); err != nil { - log.Fatal("Could not start CPU profile: ", err) - } + cmd.StartCPUProfile() defer pprof.StopCPUProfile() } diff --git a/cmd/Import-Export/main.go b/cmd/Import-Export/main.go index ac653ca3..a2d02cbb 100644 --- a/cmd/Import-Export/main.go +++ b/cmd/Import-Export/main.go @@ -18,19 +18,18 @@ package main import ( - "os" "runtime/pprof" "github.com/ProtonMail/proton-bridge/internal/cmd" "github.com/ProtonMail/proton-bridge/internal/events" "github.com/ProtonMail/proton-bridge/internal/frontend" "github.com/ProtonMail/proton-bridge/internal/importexport" + "github.com/ProtonMail/proton-bridge/internal/updates" "github.com/ProtonMail/proton-bridge/internal/users/credentials" "github.com/ProtonMail/proton-bridge/pkg/config" "github.com/ProtonMail/proton-bridge/pkg/constants" "github.com/ProtonMail/proton-bridge/pkg/listener" "github.com/ProtonMail/proton-bridge/pkg/pmapi" - "github.com/ProtonMail/proton-bridge/pkg/updates" "github.com/allan-simon/go-singleinstance" "github.com/sirupsen/logrus" "github.com/urfave/cli" @@ -47,8 +46,8 @@ var ( func main() { cmd.Main( - "ProtonMail Import/Export", - "ProtonMail Import/Export tool", + "ProtonMail Import-Export", + "ProtonMail Import-Export app", nil, run, ) @@ -66,7 +65,7 @@ func run(context *cli.Context) (contextError error) { // nolint[funlen] // report which will not be possible if no folder can be created. That's the // only problem we will not be notified about in any way. panicHandler := &cmd.PanicHandler{ - AppName: "ProtonMail Import/Export", + AppName: "ProtonMail Import-Export", Config: cfg, Err: &contextError, } @@ -81,7 +80,7 @@ func run(context *cli.Context) (contextError error) { // nolint[funlen] logLevel := context.GlobalString("log-level") _, _ = config.SetupLog(cfg, logLevel) - // Doesn't make sense to continue when Import/Export was invoked with wrong arguments. + // Doesn't make sense to continue when Import-Export was invoked with wrong arguments. // We should tell that to the user before we do anything else. if context.Args().First() != "" { _ = cli.ShowAppHelp(context) @@ -89,7 +88,7 @@ func run(context *cli.Context) (contextError error) { // nolint[funlen] } // It's safe to get version JSON file even when other instance is running. - // (thus we put it before check of presence of other Import/Export instance). + // (thus we put it before check of presence of other Import-Export instance). updates := updates.NewImportExport(cfg.GetUpdateDir()) if dir := context.GlobalString("version-json"); dir != "" { @@ -97,24 +96,18 @@ func run(context *cli.Context) (contextError error) { // nolint[funlen] return nil } - // Now we can try to proceed with starting the import/export. First we need to ensure + // Now we can try to proceed with starting the Import-Export. First we need to ensure // this is the only instance. If not, we will end and focus the existing one. lock, err := singleinstance.CreateLockFile(cfg.GetLockPath()) if err != nil { - log.Warn("Import/Export is already running") - return cli.NewExitError("Import/Export is already running.", 3) + log.Warn("Import-Export app is already running") + return cli.NewExitError("Import-Export app is already running.", 3) } defer lock.Close() //nolint[errcheck] // In case user wants to do CPU or memory profiles... if doCPUProfile := context.GlobalBool("cpu-prof"); doCPUProfile { - f, err := os.Create("cpu.pprof") - if err != nil { - log.Fatal("Could not create CPU profile: ", err) - } - if err := pprof.StartCPUProfile(f); err != nil { - log.Fatal("Could not start CPU profile: ", err) - } + cmd.StartCPUProfile() defer pprof.StopCPUProfile() } @@ -122,8 +115,8 @@ func run(context *cli.Context) (contextError error) { // nolint[funlen] defer cmd.MakeMemoryProfile() } - // Now we initialize all Import/Export parts. - log.Debug("Initializing import/export...") + // Now we initialize all Import-Export parts. + log.Debug("Initializing import-export...") eventListener := listener.New() events.SetupEvents(eventListener) @@ -141,7 +134,7 @@ func run(context *cli.Context) (contextError error) { // nolint[funlen] importexportInstance := importexport.New(cfg, panicHandler, eventListener, cm, credentialsStore) - // Decide about frontend mode before initializing rest of import/export. + // Decide about frontend mode before initializing rest of import-export. var frontendMode string switch { case context.GlobalBool("cli"): diff --git a/doc/importexport.md b/doc/importexport.md index e8449ced..8dd2303b 100644 --- a/doc/importexport.md +++ b/doc/importexport.md @@ -1,15 +1,15 @@ -# Import/Export +# Import-Export ## Main blocks -This is basic overview of the main import/export blocks. +This is basic overview of the main import-export blocks. ```mermaid graph LR S[ProtonMail server] U[User] - subgraph "Import/Export app" + subgraph "Import-Export app" Users Frontend["Qt / CLI"] ImportExport @@ -35,7 +35,7 @@ graph LR ## Code structure -More detailed graph of main types used in Import/Export app and connection between them. +More detailed graph of main types used in Import-Export app and connection between them. ```mermaid graph TD @@ -44,9 +44,9 @@ graph TD MBOX[MBOX] IMAP[IMAP] - subgraph "Import/Export app" - subgraph PkgUsers - subgraph PkgCredentials + subgraph "Import-Export app" + subgraph "pkg users" + subgraph "pkg credentials" CredStore[Store] Creds[Credentials] @@ -59,16 +59,16 @@ graph TD US --> U end - subgraph PkgFrontend + subgraph "pkg frontend" CLI Qt end - subgraph PkgImportExport + subgraph "pkg importExport" IE[ImportExport] end - subgraph PkgTransfer + subgraph "pkg transfer" Transfer Rules Progress diff --git a/doc/index.md b/doc/index.md index b6d3a543..4791f34a 100644 --- a/doc/index.md +++ b/doc/index.md @@ -9,6 +9,6 @@ Documentation pages in order to read for a novice: * [Communication between Bridge, Client and Server](communication.md) * [Encryption](encryption.md) -## Import/Export +## Import-Export -* [Import/Export code](importexport.md) +* [Import-Export code](importexport.md) diff --git a/internal/bridge/credits.go b/internal/bridge/credits.go index 2df79e67..0efc59ab 100644 --- a/internal/bridge/credits.go +++ b/internal/bridge/credits.go @@ -15,8 +15,8 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -// Code generated by ./credits.sh at Wed 29 Jul 2020 10:20:09 AM CEST. DO NOT EDIT. +// Code generated by ./credits.sh at Wed Aug 12 09:33:24 CEST 2020. DO NOT EDIT. package bridge -const Credits = "github.com/0xAX/notificator;github.com/abiosoft/ishell;github.com/abiosoft/readline;github.com/allan-simon/go-singleinstance;github.com/andybalholm/cascadia;github.com/certifi/gocertifi;github.com/chzyer/logex;github.com/chzyer/test;github.com/cucumber/godog;github.com/docker/docker-credential-helpers;github.com/emersion/go-imap;github.com/emersion/go-imap-appendlimit;github.com/emersion/go-imap-idle;github.com/emersion/go-imap-move;github.com/emersion/go-imap-quota;github.com/emersion/go-imap-specialuse;github.com/emersion/go-imap-unselect;github.com/emersion/go-sasl;github.com/emersion/go-smtp;github.com/emersion/go-textwrapper;github.com/emersion/go-vcard;github.com/fatih/color;github.com/flynn-archive/go-shlex;github.com/getsentry/raven-go;github.com/golang/mock;github.com/google/go-cmp;github.com/google/uuid;github.com/gopherjs/gopherjs;github.com/go-resty/resty/v2;github.com/hashicorp/go-multierror;github.com/jameskeane/bcrypt;github.com/jaytaylor/html2text;github.com/jhillyerd/enmime;github.com/kardianos/osext;github.com/keybase/go-keychain;github.com/logrusorgru/aurora;github.com/mattn/go-runewidth;github.com/miekg/dns;github.com/myesui/uuid;github.com/nsf/jsondiff;github.com/olekukonko/tablewriter;github.com/pkg/errors;github.com/ProtonMail/bcrypt;github.com/ProtonMail/crypto;github.com/ProtonMail/docker-credential-helpers;github.com/ProtonMail/go-appdir;github.com/ProtonMail/go-apple-mobileconfig;github.com/ProtonMail/go-autostart;github.com/ProtonMail/go-imap-id;github.com/ProtonMail/gopenpgp/v2;github.com/ProtonMail/go-smtp;github.com/ProtonMail/go-vcard;github.com/sirupsen/logrus;github.com/skratchdot/open-golang;github.com/stretchr/testify;github.com/therecipe/qt;github.com/twinj/uuid;github.com/urfave/cli;go.etcd.io/bbolt;golang.org/x/crypto;golang.org/x/net;golang.org/x/text;gopkg.in/stretchr/testify.v1;;Font Awesome 4.7.0;;Qt 5.13 by Qt group;" +const Credits = "github.com/0xAX/notificator;github.com/ProtonMail/bcrypt;github.com/ProtonMail/crypto;github.com/ProtonMail/docker-credential-helpers;github.com/ProtonMail/go-appdir;github.com/ProtonMail/go-apple-mobileconfig;github.com/ProtonMail/go-autostart;github.com/ProtonMail/go-imap-id;github.com/ProtonMail/go-smtp;github.com/ProtonMail/go-vcard;github.com/ProtonMail/gopenpgp/v2;github.com/abiosoft/ishell;github.com/abiosoft/readline;github.com/allan-simon/go-singleinstance;github.com/andybalholm/cascadia;github.com/certifi/gocertifi;github.com/chzyer/logex;github.com/chzyer/test;github.com/cucumber/godog;github.com/docker/docker-credential-helpers;github.com/emersion/go-imap;github.com/emersion/go-imap-appendlimit;github.com/emersion/go-imap-idle;github.com/emersion/go-imap-move;github.com/emersion/go-imap-quota;github.com/emersion/go-imap-specialuse;github.com/emersion/go-imap-unselect;github.com/emersion/go-mbox;github.com/emersion/go-message;github.com/emersion/go-sasl;github.com/emersion/go-smtp;github.com/emersion/go-textwrapper;github.com/emersion/go-vcard;github.com/fatih/color;github.com/flynn-archive/go-shlex;github.com/getsentry/raven-go;github.com/go-delve/delve;github.com/go-resty/resty/v2;github.com/golang/mock;github.com/google/go-cmp;github.com/google/uuid;github.com/gopherjs/gopherjs;github.com/hashicorp/go-multierror;github.com/jameskeane/bcrypt;github.com/jaytaylor/html2text;github.com/jhillyerd/enmime;github.com/kardianos/osext;github.com/keybase/go-keychain;github.com/logrusorgru/aurora;github.com/mattn/go-runewidth;github.com/miekg/dns;github.com/myesui/uuid;github.com/nsf/jsondiff;github.com/olekukonko/tablewriter;github.com/pkg/errors;github.com/psampaz/go-mod-outdated;github.com/sirupsen/logrus;github.com/skratchdot/open-golang;github.com/stretchr/testify;github.com/therecipe/qt;github.com/twinj/uuid;github.com/urfave/cli;go.etcd.io/bbolt;golang.org/x/crypto;golang.org/x/net;golang.org/x/text;gopkg.in/stretchr/testify.v1;;Font Awesome 4.7.0;;Qt 5.13 by Qt group;" diff --git a/internal/cmd/memory_profile.go b/internal/cmd/profiles.go similarity index 75% rename from internal/cmd/memory_profile.go rename to internal/cmd/profiles.go index 5b09bf54..71fab3c5 100644 --- a/internal/cmd/memory_profile.go +++ b/internal/cmd/profiles.go @@ -24,12 +24,23 @@ import ( "runtime/pprof" ) +// StartCPUProfile starts CPU pprof. +func StartCPUProfile() { + f, err := os.Create("./cpu.pprof") + if err != nil { + log.Fatal("Could not create CPU profile: ", err) + } + if err := pprof.StartCPUProfile(f); err != nil { + log.Fatal("Could not start CPU profile: ", err) + } +} + // MakeMemoryProfile generates memory pprof. func MakeMemoryProfile() { name := "./mem.pprof" f, err := os.Create(name) if err != nil { - log.Error("Could not create memory profile: ", err) + log.Fatal("Could not create memory profile: ", err) } if abs, err := filepath.Abs(name); err == nil { name = abs @@ -37,7 +48,7 @@ func MakeMemoryProfile() { log.Info("Writing memory profile to ", name) runtime.GC() // get up-to-date statistics if err := pprof.WriteHeapProfile(f); err != nil { - log.Error("Could not write memory profile: ", err) + log.Fatal("Could not write memory profile: ", err) } _ = f.Close() } diff --git a/internal/cmd/version_file.go b/internal/cmd/version_file.go index fe0e8e9c..bb832254 100644 --- a/internal/cmd/version_file.go +++ b/internal/cmd/version_file.go @@ -17,7 +17,7 @@ package cmd -import "github.com/ProtonMail/proton-bridge/pkg/updates" +import "github.com/ProtonMail/proton-bridge/internal/updates" // GenerateVersionFiles writes a JSON file with details about current build. // Those files are used for upgrading the app. diff --git a/internal/frontend/cli-ie/account_utils.go b/internal/frontend/cli-ie/account_utils.go index d016ce26..0988fcaa 100644 --- a/internal/frontend/cli-ie/account_utils.go +++ b/internal/frontend/cli-ie/account_utils.go @@ -15,7 +15,7 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -package cli +package cliie import ( "fmt" diff --git a/internal/frontend/cli-ie/accounts.go b/internal/frontend/cli-ie/accounts.go index ae3764f3..b0d25130 100644 --- a/internal/frontend/cli-ie/accounts.go +++ b/internal/frontend/cli-ie/accounts.go @@ -15,7 +15,7 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -package cli +package cliie import ( "strings" diff --git a/internal/frontend/cli-ie/frontend.go b/internal/frontend/cli-ie/frontend.go index cf769fe6..3388a679 100644 --- a/internal/frontend/cli-ie/frontend.go +++ b/internal/frontend/cli-ie/frontend.go @@ -15,8 +15,8 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -// Package cli provides CLI interface of the Import/Export. -package cli +// Package cliie provides CLI interface of the Import-Export app. +package cliie import ( "github.com/ProtonMail/proton-bridge/internal/events" @@ -68,7 +68,7 @@ func New( //nolint[funlen] Aliases: []string{"cl"}, } clearCmd.AddCmd(&ishell.Cmd{Name: "accounts", - Help: "remove all accounts from keychain. (aliases: k, keychain)", + Help: "remove all accounts from keychain. (aliases: a, k, keychain)", Aliases: []string{"a", "k", "keychain"}, Func: fe.deleteAccounts, }) @@ -77,7 +77,7 @@ func New( //nolint[funlen] // Check commands. checkCmd := &ishell.Cmd{Name: "check", Help: "check internet connection or new version."} checkCmd.AddCmd(&ishell.Cmd{Name: "updates", - Help: "check for Import/Export updates. (aliases: u, v, version)", + Help: "check for Import-Export updates. (aliases: u, v, version)", Aliases: []string{"u", "version", "v"}, Func: fe.checkUpdates, }) @@ -134,7 +134,7 @@ func New( //nolint[funlen] Completer: fe.completeUsernames, }) - // Import/Export commands. + // Import-Export commands. importCmd := &ishell.Cmd{Name: "import", Help: "import messages. (alias: imp)", Aliases: []string{"imp"}, @@ -167,7 +167,7 @@ func New( //nolint[funlen] // System commands. fe.AddCmd(&ishell.Cmd{Name: "restart", - Help: "restart the import/export.", + Help: "restart the Import-Export app.", Func: fe.restart, }) @@ -190,7 +190,7 @@ func (f *frontendCLI) watchEvents() { for { select { case errorDetails := <-errorCh: - f.Println("Import/Export failed:", errorDetails) + f.Println("Import-Export failed:", errorDetails) case <-internetOffCh: f.notifyInternetOff() case <-internetOnCh: @@ -228,9 +228,9 @@ func (f *frontendCLI) Loop(credentialsError error) error { } f.Print(` -Welcome to ProtonMail Import/Export interactive shell +Welcome to ProtonMail Import-Export interactive shell -WARNING: CLI is experimental feature and does not cover all functionality yet. +WARNING: The CLI is an experimental feature and does not yet cover all functionality. `) f.Run() return nil diff --git a/internal/frontend/cli-ie/importexport.go b/internal/frontend/cli-ie/importexport.go index 05db81bf..fe89d7df 100644 --- a/internal/frontend/cli-ie/importexport.go +++ b/internal/frontend/cli-ie/importexport.go @@ -15,7 +15,7 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -package cli +package cliie import ( "fmt" diff --git a/internal/frontend/cli-ie/system.go b/internal/frontend/cli-ie/system.go index 2df6574e..103b4511 100644 --- a/internal/frontend/cli-ie/system.go +++ b/internal/frontend/cli-ie/system.go @@ -15,19 +15,15 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -package cli +package cliie import ( "github.com/abiosoft/ishell" ) -var ( - currentPort = "" //nolint[gochecknoglobals] -) - func (f *frontendCLI) restart(c *ishell.Context) { - if f.yesNoQuestion("Are you sure you want to restart the Import/Export") { - f.Println("Restarting Import/Export...") + if f.yesNoQuestion("Are you sure you want to restart the Import-Export") { + f.Println("Restarting the Import-Export app...") f.appRestart = true f.Stop() } @@ -37,7 +33,7 @@ func (f *frontendCLI) checkInternetConnection(c *ishell.Context) { if f.ie.CheckConnection() == nil { f.Println("Internet connection is available.") } else { - f.Println("Can not contact the server, please check you internet connection.") + f.Println("Can not contact the server, please check your internet connection.") } } @@ -46,5 +42,5 @@ func (f *frontendCLI) printLogDir(c *ishell.Context) { } func (f *frontendCLI) printManual(c *ishell.Context) { - f.Println("More instructions about the Import/Export can be found at\n\n https://protonmail.com/support/categories/import-export/") + f.Println("More instructions about the Import-Export app can be found at\n\n https://protonmail.com/support/categories/import-export/") } diff --git a/internal/frontend/cli-ie/updates.go b/internal/frontend/cli-ie/updates.go index 62332dc8..cabebd79 100644 --- a/internal/frontend/cli-ie/updates.go +++ b/internal/frontend/cli-ie/updates.go @@ -15,13 +15,13 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -package cli +package cliie import ( "strings" "github.com/ProtonMail/proton-bridge/internal/importexport" - "github.com/ProtonMail/proton-bridge/pkg/updates" + "github.com/ProtonMail/proton-bridge/internal/updates" "github.com/abiosoft/ishell" ) @@ -47,7 +47,7 @@ func (f *frontendCLI) printLocalReleaseNotes(c *ishell.Context) { } func (f *frontendCLI) printReleaseNotes(versionInfo updates.VersionInfo) { - f.Println(bold("ProtonMail Import/Export "+versionInfo.Version), "\n") + f.Println(bold("ProtonMail Import-Export "+versionInfo.Version), "\n") if versionInfo.ReleaseNotes != "" { f.Println(bold("Release Notes")) f.Println(versionInfo.ReleaseNotes) diff --git a/internal/frontend/cli-ie/utils.go b/internal/frontend/cli-ie/utils.go index 6f01d2fe..b2de679a 100644 --- a/internal/frontend/cli-ie/utils.go +++ b/internal/frontend/cli-ie/utils.go @@ -15,7 +15,7 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -package cli +package cliie import ( "strings" @@ -98,7 +98,7 @@ func (f *frontendCLI) notifyNeedUpgrade() { func (f *frontendCLI) notifyCredentialsError() { // Print in 80-column width. - f.Println("ProtonMail Import/Export is not able to detect a supported password manager") + f.Println("ProtonMail Import-Export is not able to detect a supported password manager") f.Println("(pass, gnome-keyring). Please install and set up a supported password manager") f.Println("and restart the application.") } @@ -109,7 +109,7 @@ func (f *frontendCLI) notifyCertIssue() { be insecure. Description: -ProtonMail Import/Export was not able to establish a secure connection to Proton +ProtonMail Import-Export was not able to establish a secure connection to Proton servers due to a TLS certificate error. This means your connection may potentially be insecure and susceptible to monitoring by third parties. diff --git a/internal/frontend/cli/frontend.go b/internal/frontend/cli/frontend.go index 0df33e7b..82c0dbc6 100644 --- a/internal/frontend/cli/frontend.go +++ b/internal/frontend/cli/frontend.go @@ -76,7 +76,7 @@ func New( //nolint[funlen] Func: fe.deleteCache, }) clearCmd.AddCmd(&ishell.Cmd{Name: "accounts", - Help: "remove all accounts from keychain. (aliases: k, keychain)", + Help: "remove all accounts from keychain. (aliases: a, k, keychain)", Aliases: []string{"a", "k", "keychain"}, Func: fe.deleteAccounts, }) diff --git a/internal/frontend/cli/system.go b/internal/frontend/cli/system.go index a32dcc9c..fe01fa4a 100644 --- a/internal/frontend/cli/system.go +++ b/internal/frontend/cli/system.go @@ -43,7 +43,7 @@ func (f *frontendCLI) checkInternetConnection(c *ishell.Context) { if f.bridge.CheckConnection() == nil { f.Println("Internet connection is available.") } else { - f.Println("Can not contact the server, please check you internet connection.") + f.Println("Can not contact the server, please check your internet connection.") } } diff --git a/internal/frontend/cli/updates.go b/internal/frontend/cli/updates.go index b30da468..44951fec 100644 --- a/internal/frontend/cli/updates.go +++ b/internal/frontend/cli/updates.go @@ -21,7 +21,7 @@ import ( "strings" "github.com/ProtonMail/proton-bridge/internal/bridge" - "github.com/ProtonMail/proton-bridge/pkg/updates" + "github.com/ProtonMail/proton-bridge/internal/updates" "github.com/abiosoft/ishell" ) diff --git a/internal/frontend/qml/ImportExportUI/DialogImport.qml b/internal/frontend/qml/ImportExportUI/DialogImport.qml index c4fd4c1b..2dce1ea8 100644 --- a/internal/frontend/qml/ImportExportUI/DialogImport.qml +++ b/internal/frontend/qml/ImportExportUI/DialogImport.qml @@ -458,7 +458,7 @@ Dialog { if (progressbarImport.isFinished) return qsTr("Import finished","todo") if ( go.progressDescription == gui.enums.progressInit || - (go.progress == 0 && go.description=="") + (go.progress == 0 && go.progressDescription=="") ) return qsTr("Estimating the total number of messages","todo") if ( go.progressDescription == gui.enums.progressLooping diff --git a/internal/frontend/qml/ImportExportUI/ImportDelegate.qml b/internal/frontend/qml/ImportExportUI/ImportDelegate.qml index aa916186..f59e6d7b 100644 --- a/internal/frontend/qml/ImportExportUI/ImportDelegate.qml +++ b/internal/frontend/qml/ImportExportUI/ImportDelegate.qml @@ -43,6 +43,8 @@ Rectangle { property string lastTargetFolder: "6" // Archive property string lastTargetLabels: "" // no flag by default + property string sourceID : mboxID + property string sourceName : name Rectangle { id: line @@ -71,7 +73,7 @@ Rectangle { Text { id: folderIcon - text : gui.folderIcon(name, gui.enums.folderTypeFolder) + text : gui.folderIcon(root.sourceName, gui.enums.folderTypeFolder) anchors.verticalCenter : parent.verticalCenter color: root.isSourceSelected ? Style.main.text : Style.main.textDisabled font { @@ -81,7 +83,7 @@ Rectangle { } Text { - text : name + text : root.sourceName width: nameWidth elide: Text.ElideRight anchors.verticalCenter : parent.verticalCenter @@ -102,8 +104,8 @@ Rectangle { SelectFolderMenu { id: selectFolder - sourceID: mboxID - targets: transferRules.targetFolders(mboxID) + sourceID: root.sourceID + targets: transferRules.targetFolders(root.sourceID) width: nameWidth anchors.verticalCenter : parent.verticalCenter enabled: root.isSourceSelected @@ -112,8 +114,8 @@ Rectangle { } SelectLabelsMenu { - sourceID: mboxID - targets: transferRules.targetLabels(mboxID) + sourceID: root.sourceID + targets: transferRules.targetLabels(root.sourceID) width: nameWidth anchors.verticalCenter : parent.verticalCenter enabled: root.isSourceSelected @@ -130,7 +132,7 @@ Rectangle { DateRangeMenu { id: dateRangeMenu - sourceID: mboxID + sourceID: root.sourceID sourceFromDate: fromDate sourceToDate: toDate @@ -143,10 +145,10 @@ Rectangle { function importToFolder(newTargetID) { - transferRules.addTargetID(mboxID,newTargetID) + transferRules.addTargetID(root.sourceID,newTargetID) } function toggleImport() { - transferRules.setIsRuleActive(mboxID, !root.isSourceSelected) + transferRules.setIsRuleActive(root.sourceID, !root.isSourceSelected) } } diff --git a/internal/frontend/qml/ImportExportUI/ImportStructure.qml b/internal/frontend/qml/ImportExportUI/ImportStructure.qml index 17956039..9628b7d7 100644 --- a/internal/frontend/qml/ImportExportUI/ImportStructure.qml +++ b/internal/frontend/qml/ImportExportUI/ImportStructure.qml @@ -110,7 +110,7 @@ Rectangle { left: parent.left verticalCenter: parent.verticalCenter leftMargin: { - if (listview.currentIndex<0) return 0 + if (listview.currentItem === null) return 0 else return listview.currentItem.leftMargin1 } } diff --git a/internal/frontend/qml/ImportExportUI/MainWindow.qml b/internal/frontend/qml/ImportExportUI/MainWindow.qml index 2531c803..48e5353f 100644 --- a/internal/frontend/qml/ImportExportUI/MainWindow.qml +++ b/internal/frontend/qml/ImportExportUI/MainWindow.qml @@ -112,7 +112,7 @@ Window { rightMargin: innerWindowBorder } model: [ - { "title" : qsTr("Import/Export" , "title of tab that shows account list" ), "iconText": Style.fa.home }, + { "title" : qsTr("Import-Export" , "title of tab that shows account list" ), "iconText": Style.fa.home }, { "title" : qsTr("Settings" , "title of tab that allows user to change settings" ), "iconText": Style.fa.cogs }, { "title" : qsTr("Help" , "title of tab that shows the help menu" ), "iconText": Style.fa.life_ring } ] @@ -381,8 +381,9 @@ Window { onClickedNo: popupMessage.hide() onClickedOkay: popupMessage.hide() + onClickedCancel: popupMessage.hide() onClickedYes: { - if (popupMessage.message == gui.areYouSureYouWantToQuit) Qt.quit() + if (popupMessage.text == gui.areYouSureYouWantToQuit) Qt.quit() } } @@ -461,8 +462,9 @@ Window { (dialogExport.visible && dialogExport.currentIndex == 2 && go.progress!=1) ) { popupMessage.buttonOkay .visible = false - popupMessage.buttonNo .visible = true - popupMessage.buttonYes .visible = true + popupMessage.buttonYes .visible = false + popupMessage.buttonQuit .visible = true + popupMessage.buttonCancel .visible = true popupMessage.show ( gui.areYouSureYouWantToQuit ) return } diff --git a/internal/frontend/qml/ImportExportUI/SelectFolderMenu.qml b/internal/frontend/qml/ImportExportUI/SelectFolderMenu.qml index 2dc3b20d..5a1e7c20 100644 --- a/internal/frontend/qml/ImportExportUI/SelectFolderMenu.qml +++ b/internal/frontend/qml/ImportExportUI/SelectFolderMenu.qml @@ -58,7 +58,6 @@ ComboBox { } displayText: { - console.log("Target Menu current", view.currentItem, view.currentIndex) if (view.currentIndex >= 0) { if (!root.isFolderType) return Style.fa.tags + " " + qsTr("Add/Remove labels") diff --git a/internal/frontend/qml/ProtonUI/PopupMessage.qml b/internal/frontend/qml/ProtonUI/PopupMessage.qml index 5c2f32ae..ffaa4556 100644 --- a/internal/frontend/qml/ProtonUI/PopupMessage.qml +++ b/internal/frontend/qml/ProtonUI/PopupMessage.qml @@ -25,6 +25,7 @@ Rectangle { color: Style.transparent property alias text : message.text property alias checkbox : checkbox + property alias buttonQuit : buttonQuit property alias buttonOkay : buttonOkay property alias buttonYes : buttonYes property alias buttonNo : buttonNo @@ -89,13 +90,13 @@ Rectangle { spacing: Style.dialog.spacing anchors.horizontalCenter : parent.horizontalCenter + ButtonRounded { id : buttonQuit ; text : qsTr ( "Stop & quit", "" ) ; onClicked : root.clickedYes ( ) ; visible : false ; isOpaque : true ; color_main : Style.errorDialog.text ; color_minor : Style.dialog.textBlue ; } ButtonRounded { id : buttonNo ; text : qsTr ( "No" , "Button No" ) ; onClicked : root.clickedNo ( ) ; visible : false ; isOpaque : false ; color_main : Style.errorDialog.text ; color_minor : Style.transparent ; } ButtonRounded { id : buttonYes ; text : qsTr ( "Yes" , "Button Yes" ) ; onClicked : root.clickedYes ( ) ; visible : false ; isOpaque : true ; color_main : Style.errorDialog.text ; color_minor : Style.dialog.textBlue ; } ButtonRounded { id : buttonRetry ; text : qsTr ( "Retry" , "Button Retry" ) ; onClicked : root.clickedRetry ( ) ; visible : false ; isOpaque : false ; color_main : Style.errorDialog.text ; color_minor : Style.transparent ; } ButtonRounded { id : buttonSkip ; text : qsTr ( "Skip" , "Button Skip" ) ; onClicked : root.clickedSkip ( ) ; visible : false ; isOpaque : false ; color_main : Style.errorDialog.text ; color_minor : Style.transparent ; } ButtonRounded { id : buttonCancel ; text : qsTr ( "Cancel" , "Button Cancel" ) ; onClicked : root.clickedCancel ( ) ; visible : false ; isOpaque : true ; color_main : Style.errorDialog.text ; color_minor : Style.dialog.textBlue ; } ButtonRounded { id : buttonOkay ; text : qsTr ( "Okay" , "Button Okay" ) ; onClicked : root.clickedOkay ( ) ; visible : true ; isOpaque : true ; color_main : Style.errorDialog.text ; color_minor : Style.dialog.textBlue ; } - } } } diff --git a/internal/frontend/qml/tst_GuiIE.qml b/internal/frontend/qml/tst_GuiIE.qml index c96f4ce1..48dcc460 100644 --- a/internal/frontend/qml/tst_GuiIE.qml +++ b/internal/frontend/qml/tst_GuiIE.qml @@ -99,7 +99,7 @@ Window { id: buttons ListElement { title : "Show window" } - ListElement { title : "Logout cuthix" } + ListElement { title : "Logout" } ListElement { title : "Internet on" } ListElement { title : "Internet off" } ListElement { title : "Macos" } @@ -143,8 +143,8 @@ Window { case "Show window" : go.showWindow(); break; - case "Logout cuthix" : - go.checkLoggedOut("cuthix"); + case "Logout" : + go.checkLoggedOut("ie"); break; case "Internet on" : go.setConnectionStatus(true); @@ -223,10 +223,10 @@ Window { ListModel{ id: accountsModel - ListElement{ account : "cuthix" ; status : "connected"; isExpanded: false; isCombinedAddressMode: false; hostname : "127.0.0.1"; password : "ZI9tKp+ryaxmbpn2E12"; security : "StarTLS"; portSMTP : 1025; portIMAP : 1143; aliases : "cuto@pm.com;jaku@pm.com;DoYouKnowAboutAMovieCalledTheHorriblySlowMurderWithExtremelyInefficientWeapon@thatYouCanFindForExampleOnyoutube.com" } - ListElement{ account : "exteremelongnamewhichmustbeeladedinthemiddleoftheaddress@protonmail.com" ; status : "connected"; isExpanded: true; isCombinedAddressMode: true; hostname : "127.0.0.1"; password : "ZI9tKp+ryaxmbpn2E12"; security : "StarTLS"; portSMTP : 1025; portIMAP : 1143; aliases : "cuto@pm.com;jaku@pm.com;hu@hu.hu" } - ListElement{ account : "cuthix2@protonmail.com" ; status : "disconnected"; isExpanded: false; isCombinedAddressMode: false; hostname : "127.0.0.1"; password : "ZI9tKp+ryaxmbpn2E12"; security : "StarTLS"; portSMTP : 1025; portIMAP : 1143; aliases : "cuto@pm.com;jaku@pm.com;hu@hu.hu" } - ListElement{ account : "many@protonmail.com" ; status : "connected"; isExpanded: true; isCombinedAddressMode: true; hostname : "127.0.0.1"; password : "ZI9tKp+ryaxmbpn2E12"; security : "StarTLS"; portSMTP : 1025; portIMAP : 1143; aliases : "cuto@pm.com;jaku@pm.com;hu@hu.hu;cuto@pm.com;jaku@pm.com;hu@hu.hu;cuto@pm.com;jaku@pm.com;hu@hu.hu;cuto@pm.com;jaku@pm.com;hu@hu.hu;cuto@pm.com;jaku@pm.com;hu@hu.hu;cuto@pm.com;jaku@pm.com;hu@hu.hu;cuto@pm.com;jaku@pm.com;hu@hu.hu;cuto@pm.com;jaku@pm.com;hu@hu.hu;cuto@pm.com;jaku@pm.com;hu@hu.hu;cuto@pm.com;jaku@pm.com;hu@hu.hu;cuto@pm.com;jaku@pm.com;hu@hu.hu;cuto@pm.com;jaku@pm.com;hu@hu.hu;cuto@pm.com;jaku@pm.com;hu@hu.hu;cuto@pm.com;jaku@pm.com;hu@hu.hu;cuto@pm.com;jaku@pm.com;hu@hu.hu;"} + ListElement{ account : "ie" ; status : "connected"; isExpanded: false; isCombinedAddressMode: false; hostname : "127.0.0.1"; password : "ZI9tKp+ryaxmbpn2E12"; security : "StarTLS"; portSMTP : 1025; portIMAP : 1143; aliases : "ie@pm.com;jaku@pm.com;DoYouKnowAboutAMovieCalledTheHorriblySlowMurderWithExtremelyInefficientWeapon@thatYouCanFindForExampleOnyoutube.com" } + ListElement{ account : "exteremelongnamewhichmustbeeladedinthemiddleoftheaddress@protonmail.com" ; status : "connected"; isExpanded: true; isCombinedAddressMode: true; hostname : "127.0.0.1"; password : "ZI9tKp+ryaxmbpn2E12"; security : "StarTLS"; portSMTP : 1025; portIMAP : 1143; aliases : "ie@pm.com;jaku@pm.com;hu@hu.hu" } + ListElement{ account : "ie2@protonmail.com" ; status : "disconnected"; isExpanded: false; isCombinedAddressMode: false; hostname : "127.0.0.1"; password : "ZI9tKp+ryaxmbpn2E12"; security : "StarTLS"; portSMTP : 1025; portIMAP : 1143; aliases : "ie@pm.com;jaku@pm.com;hu@hu.hu" } + ListElement{ account : "many@protonmail.com" ; status : "connected"; isExpanded: true; isCombinedAddressMode: true; hostname : "127.0.0.1"; password : "ZI9tKp+ryaxmbpn2E12"; security : "StarTLS"; portSMTP : 1025; portIMAP : 1143; aliases : "ie@pm.com;jaku@pm.com;hu@hu.hu;ie@pm.com;jaku@pm.com;hu@hu.hu;ie@pm.com;jaku@pm.com;hu@hu.hu;ie@pm.com;jaku@pm.com;hu@hu.hu;ie@pm.com;jaku@pm.com;hu@hu.hu;ie@pm.com;jaku@pm.com;hu@hu.hu;ie@pm.com;jaku@pm.com;hu@hu.hu;ie@pm.com;jaku@pm.com;hu@hu.hu;ie@pm.com;jaku@pm.com;hu@hu.hu;ie@pm.com;jaku@pm.com;hu@hu.hu;ie@pm.com;jaku@pm.com;hu@hu.hu;ie@pm.com;jaku@pm.com;hu@hu.hu;ie@pm.com;jaku@pm.com;hu@hu.hu;ie@pm.com;jaku@pm.com;hu@hu.hu;ie@pm.com;jaku@pm.com;hu@hu.hu;"} } ListModel{ @@ -830,9 +830,9 @@ Window { property string bugNotSent property string bugReportSent - property string programTitle : "ProtonMail Import/Export Tool" + property string programTitle : "ProtonMail Import-Export App" property string newversion : "q0.1.0" - property string landingPage : "https://jakub.cuth.sk/bridge" + property string landingPage : "https://landing.page" property string changelog : "• Lorem ipsum dolor sit amet\n• consetetur sadipscing elitr,\n• sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat,\n• sed diam voluptua.\n• At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet." //property string changelog : "" property string bugfixes : "• lorem ipsum dolor sit amet;• consetetur sadipscing elitr;• sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat;• sed diam voluptua;• at vero eos et accusam et justo duo dolores et ea rebum;• stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet" diff --git a/internal/frontend/qt-common/common.go b/internal/frontend/qt-common/common.go index 644eefb6..d0143d48 100644 --- a/internal/frontend/qt-common/common.go +++ b/internal/frontend/qt-common/common.go @@ -103,16 +103,6 @@ func PauseLong() { time.Sleep(3 * time.Second) } -func ParsePMAPIError(err error, code int) error { - /* - if err == pmapi.ErrAPINotReachable { - code = ErrNoInternet - } - return errors.NewFromError(code, err) - */ - return nil -} - // FIXME: Not working in test... func WaitForEnter() { log.Print("Press 'Enter' to continue...") diff --git a/internal/frontend/qt-ie/export.go b/internal/frontend/qt-ie/export.go index e851ff02..e53922e1 100644 --- a/internal/frontend/qt-ie/export.go +++ b/internal/frontend/qt-ie/export.go @@ -47,6 +47,18 @@ func (f *FrontendQt) LoadStructureForExport(addressOrID string) { return } + // Export has only one option to set time limits--by global time range. + // In case user changes file or because of some bug global time is saved + // to all rules, let's clear it, because there is no way to show it in + // GUI and user would be confused and see it does not work at all. + for _, rule := range f.transfer.GetRules() { + isActive := rule.Active + f.transfer.SetRule(rule.SourceMailbox, rule.TargetMailboxes, 0, 0) + if !isActive { + f.transfer.UnsetRule(rule.SourceMailbox) + } + } + f.TransferRules.setTransfer(f.transfer) } @@ -65,55 +77,4 @@ func (f *FrontendQt) StartExport(rootPath, login, fileType string, attachEncrypt f.transfer.SetSkipEncryptedMessages(!attachEncryptedBody) progress := f.transfer.Start() f.setProgressManager(progress) - - /* - TODO - f.Qml.SetProgress(0.0) - f.Qml.SetProgressDescription(backend.ProgressInit) - f.Qml.SetTotal(0) - - settings := backend.ExportSettings{ - FilePath: fpath, - Login: login, - AttachEncryptedBody: attachEncryptedBody, - DateBegin: 0, - DateEnd: 0, - Labels: make(map[string]string), - } - - if fileType == "EML" { - settings.FileTypeID = backend.EMLFormat - } else if fileType == "MBOX" { - settings.FileTypeID = backend.MBOXFormat - } else { - log.Errorln("Wrong file format:", fileType) - return - } - - username, _, err := backend.ExtractUsername(login) - if err != nil { - log.Error("qtfrontend: cannot retrieve username from alias: ", err) - return - } - - settings.User, err = backend.ExtractCurrentUser(username) - if err != nil && !errors.IsCode(err, errors.ErrUnlockUser) { - return - } - - for _, entity := range f.PMStructure.entities { - if entity.IsFolderSelected { - settings.Labels[entity.FolderName] = entity.FolderId - } - } - - settings.DateBegin = f.PMStructure.GlobalOptions.FromDate - settings.DateEnd = f.PMStructure.GlobalOptions.ToDate - - settings.PM = backend.NewProcessManager() - f.setHandlers(settings.PM) - - log.Debugln("start export", settings.FilePath) - go backend.Export(f.panicHandler, settings) - */ } diff --git a/internal/frontend/qt-ie/folder_functions.go b/internal/frontend/qt-ie/folder_functions.go index 7c156a72..851d770d 100644 --- a/internal/frontend/qt-ie/folder_functions.go +++ b/internal/frontend/qt-ie/folder_functions.go @@ -59,10 +59,6 @@ func getTargetHashes(mboxes []transfer.Mailbox) (targetFolderID, targetLabelIDs return } -func isSystemMailbox(mbox transfer.Mailbox) bool { - return pmapi.IsSystemLabel(mbox.ID) -} - func newFolderInfo(mbox transfer.Mailbox, rule *transfer.Rule) *FolderInfo { targetFolderID, targetLabelIDs := getTargetHashes(rule.TargetMailboxes) @@ -77,7 +73,7 @@ func newFolderInfo(mbox transfer.Mailbox, rule *transfer.Rule) *FolderInfo { } entry.FolderType = FolderTypeSystem - if !isSystemMailbox(mbox) { + if !pmapi.IsSystemLabel(mbox.ID) { if mbox.IsExclusive { entry.FolderType = FolderTypeFolder } else { @@ -112,7 +108,7 @@ func (s *FolderStructure) saveRule(info *FolderInfo) error { return s.transfer.SetRule(sourceMbox, targetMboxes, info.FromDate, info.ToDate) } -func (s *FolderInfo) updateTgtLblIDs(targetLabelsSet map[string]struct{}) { +func (s *FolderInfo) updateTargetLabelIDs(targetLabelsSet map[string]struct{}) { targets := []string{} for key := range targetLabelsSet { targets = append(targets, key) @@ -120,17 +116,13 @@ func (s *FolderInfo) updateTgtLblIDs(targetLabelsSet map[string]struct{}) { s.TargetLabelIDs = strings.Join(targets, ";") } -func (s *FolderInfo) clearTgtLblIDs() { - s.TargetLabelIDs = "" -} - func (s *FolderInfo) AddTargetLabel(targetID string) { if targetID == "" { return } targetLabelsSet := s.getSetOfLabels() targetLabelsSet[targetID] = struct{}{} - s.updateTgtLblIDs(targetLabelsSet) + s.updateTargetLabelIDs(targetLabelsSet) } func (s *FolderInfo) RemoveTargetLabel(targetID string) { @@ -139,7 +131,7 @@ func (s *FolderInfo) RemoveTargetLabel(targetID string) { } targetLabelsSet := s.getSetOfLabels() delete(targetLabelsSet, targetID) - s.updateTgtLblIDs(targetLabelsSet) + s.updateTargetLabelIDs(targetLabelsSet) } func (s *FolderInfo) IsType(askType string) bool { @@ -387,7 +379,7 @@ func (s *FolderStructure) setTargetFolderID(id, target string) { s.changedEntityRole(i, i, TargetFolderID) if target == "" { // do not import before := info.TargetLabelIDs - info.clearTgtLblIDs() + info.TargetLabelIDs = "" if err := s.saveRule(info); err != nil { info.TargetLabelIDs = before log.WithError(err).WithField("id", id).WithField("target", target).Error("Cannot set target") diff --git a/internal/frontend/qt-ie/frontend.go b/internal/frontend/qt-ie/frontend.go index 10599904..32971fa7 100644 --- a/internal/frontend/qt-ie/frontend.go +++ b/internal/frontend/qt-ie/frontend.go @@ -29,9 +29,9 @@ import ( qtcommon "github.com/ProtonMail/proton-bridge/internal/frontend/qt-common" "github.com/ProtonMail/proton-bridge/internal/frontend/types" "github.com/ProtonMail/proton-bridge/internal/transfer" + "github.com/ProtonMail/proton-bridge/internal/updates" "github.com/ProtonMail/proton-bridge/pkg/config" "github.com/ProtonMail/proton-bridge/pkg/listener" - "github.com/ProtonMail/proton-bridge/pkg/updates" "github.com/therecipe/qt/core" "github.com/therecipe/qt/gui" @@ -185,7 +185,7 @@ func (f *FrontendQt) qtSetupQmlAndStructures() { f.View.Load(core.NewQUrl3("qrc:/uiie.qml", 0)) // TODO set the first start flag - log.Error("Get FirstStart: Not implemented") + //log.Error("Get FirstStart: Not implemented") //if prefs.Get(prefs.FirstStart) == "true" { if false { f.Qml.SetIsFirstStart(true) @@ -226,7 +226,6 @@ func (f *FrontendQt) QtExecute(Procedure func(*FrontendQt) error) error { return err } log.Debug("Closing...") - log.Error("Set FirstStart: Not implemented") //prefs.Set(prefs.FirstStart, "false") return nil } @@ -318,27 +317,31 @@ func (f *FrontendQt) setProgressManager(progress *transfer.Progress) { f.Qml.ConnectCancelProcess(func() { progress.Stop() }) + f.Qml.SetProgress(0) go func() { + log.Trace("Start reading updates") defer func() { + log.Trace("Finishing reading updates") f.Qml.DisconnectPauseProcess() f.Qml.DisconnectResumeProcess() f.Qml.DisconnectCancelProcess() f.Qml.SetProgress(1) + f.progress = nil + f.ErrorList.Progress = nil }() - //TODO get log file (in old code it was here, but this is ugly place probably somewhere else) updates := progress.GetUpdateChannel() for range updates { if progress.IsStopped() { break } failed, imported, _, _, total := progress.GetCounts() - if total != 0 { // udate total + if total != 0 { f.Qml.SetTotal(int(total)) } f.Qml.SetProgressFails(int(failed)) - f.Qml.SetProgressDescription(progress.PauseReason()) // TODO add description when changing folders? + f.Qml.SetProgressDescription(progress.PauseReason()) if total > 0 { newProgress := float32(imported+failed) / float32(total) if newProgress >= 0 && newProgress != f.Qml.Progress() { @@ -436,7 +439,7 @@ func (f *FrontendQt) getLocalVersionInfo() { // LeastUsedColor is intended to return color for creating a new inbox or label. func (f *FrontendQt) leastUsedColor() string { if f.transfer == nil { - log.Errorln("Getting least used color before transfer exist.") + log.Warnln("Getting least used color before transfer exist.") return "#7272a7" } diff --git a/internal/frontend/qt-ie/import.go b/internal/frontend/qt-ie/import.go index 6e80dda9..c26bad32 100644 --- a/internal/frontend/qt-ie/import.go +++ b/internal/frontend/qt-ie/import.go @@ -74,6 +74,8 @@ func (f *FrontendQt) loadStructuresForImport() error { } func (f *FrontendQt) StartImport(email string) { // TODO email not needed + log.Trace("Starting import") + f.Qml.SetProgressDescription("init") // TODO use const f.Qml.SetProgressFails(0) f.Qml.SetProgress(0.0) diff --git a/internal/frontend/qt-ie/mbox.go b/internal/frontend/qt-ie/mbox.go index 7f791c89..9fe33166 100644 --- a/internal/frontend/qt-ie/mbox.go +++ b/internal/frontend/qt-ie/mbox.go @@ -55,8 +55,8 @@ func newMboxList(t *TransferRules, rule *transfer.Rule, containsFolders bool) *M m.log = log. WithField("rule", m.rule.SourceMailbox.Hash()). WithField("folders", m.containsFolders) + m.updateSelectedIndex() m.EndResetModel() - m.itemsChanged(rule) return m } @@ -71,11 +71,6 @@ func (m *MboxList) rowCount(index *core.QModelIndex) int { } func (m *MboxList) roleNames() map[int]*core.QByteArray { - m.log. - WithField("isActive", MboxIsActive). - WithField("id", MboxID). - WithField("color", MboxColor). - Debug("role names") return map[int]*core.QByteArray{ MboxIsActive: qtcommon.NewQByteArrayFromString("isActive"), MboxID: qtcommon.NewQByteArrayFromString("mboxID"), @@ -88,17 +83,17 @@ func (m *MboxList) roleNames() map[int]*core.QByteArray { func (m *MboxList) data(index *core.QModelIndex, role int) *core.QVariant { allTargets := m.targetMailboxes() - i, valid := index.Row(), index.IsValid() - l := m.log.WithField("row", i).WithField("role", role) - l.Trace("called data()") + i := index.Row() + log := m.log.WithField("row", i).WithField("role", role) + log.Trace("Mbox data") - if !valid || i >= len(allTargets) { - l.WithField("row", i).Warning("Invalid index") + if i >= len(allTargets) { + log.Warning("Invalid index") return core.NewQVariant() } if m.transfer == nil { - l.Warning("Requested mbox list data before transfer is connected") + log.Warning("Requested mbox list data before transfer is connected") return qtcommon.NewQVariantString("") } @@ -131,7 +126,7 @@ func (m *MboxList) data(index *core.QModelIndex, role int) *core.QVariant { return qtcommon.NewQVariantString(mbox.Color) default: - l.Error("Requested mbox list data with unknown role") + log.Error("Requested mbox list data with unknown role") return qtcommon.NewQVariantString("") } } @@ -161,11 +156,10 @@ func (m *MboxList) filter(mailboxes []transfer.Mailbox) (filtered []transfer.Mai func (m *MboxList) itemsChanged(rule *transfer.Rule) { m.rule = rule allTargets := m.targetMailboxes() - l := m.log.WithField("count", len(allTargets)) - l.Trace("called itemChanged()") - defer func() { - l.WithField("selected", m.SelectedIndex()).Trace("index updated") - }() + + m.log.WithField("count", len(allTargets)).Trace("Mbox items changed") + + m.updateSelectedIndex() // NOTE: Be careful with indices: If they are invalid the DataChanged // signal will not be sent to QML e.g. `end == rowCount - 1` @@ -175,7 +169,10 @@ func (m *MboxList) itemsChanged(rule *transfer.Rule) { changedRoles := []int{MboxIsActive} m.DataChanged(begin, end, changedRoles) } +} +func (m *MboxList) updateSelectedIndex() { + allTargets := m.targetMailboxes() for index, targetMailbox := range allTargets { for _, selectedTarget := range m.rule.TargetMailboxes { if targetMailbox.Hash() == selectedTarget.Hash() { diff --git a/internal/frontend/qt-common/path_status.go b/internal/frontend/qt-ie/path_status.go similarity index 98% rename from internal/frontend/qt-common/path_status.go rename to internal/frontend/qt-ie/path_status.go index bf76d6d8..0216cb56 100644 --- a/internal/frontend/qt-common/path_status.go +++ b/internal/frontend/qt-ie/path_status.go @@ -15,7 +15,9 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -package qtcommon +// +build !nogui + +package qtie import ( "io/ioutil" diff --git a/internal/frontend/qt-ie/transfer_rules.go b/internal/frontend/qt-ie/transfer_rules.go index 8b88f94e..48719a79 100644 --- a/internal/frontend/qt-ie/transfer_rules.go +++ b/internal/frontend/qt-ie/transfer_rules.go @@ -44,6 +44,7 @@ type TransferRules struct { _ func(sourceID string, targetID string) `slot:"addTargetID,auto"` _ func(sourceID string, targetID string) `slot:"removeTargetID,auto"` + // globalFromDate and globalToDate is just default value for GUI, always zero. _ int `property:"globalFromDate"` _ int `property:"globalToDate"` _ bool `property:"isLabelGroupSelected"` @@ -90,21 +91,23 @@ func (t *TransferRules) roleNames() map[int]*core.QByteArray { } func (t *TransferRules) data(index *core.QModelIndex, role int) *core.QVariant { - i, valid := index.Row(), index.IsValid() - - if !valid || i >= t.rowCount(index) { - log.WithField("row", i).Warning("Invalid index") - return core.NewQVariant() - } + i := index.Row() + allRules := t.transfer.GetRules() log := log.WithField("row", i).WithField("role", role) + log.Trace("Transfer rules data") + + if i >= len(allRules) { + log.Warning("Invalid index") + return core.NewQVariant() + } if t.transfer == nil { log.Warning("Requested transfer rules data before transfer is connected") return qtcommon.NewQVariantString("") } - rule := t.transfer.GetRules()[i] + rule := allRules[i] switch role { case MboxIsActive: @@ -160,6 +163,9 @@ func (t *TransferRules) setTransfer(transfer *transfer.Transfer) { t.transfer = transfer + t.targetFoldersCache = make(map[string]*MboxList) + t.targetLabelsCache = make(map[string]*MboxList) + t.updateGroupSelection() } @@ -196,7 +202,9 @@ func (t *TransferRules) targetLabels(sourceID string) *MboxList { // Setters func (t *TransferRules) setIsGroupActive(groupName string, isActive bool) { - wantExclusive := (groupName == FolderTypeLabel) + log.WithField("group", groupName).WithField("active", isActive).Trace("Setting group as active/inactive") + + wantExclusive := (groupName == FolderTypeFolder) for _, rule := range t.transfer.GetRules() { if rule.SourceMailbox.IsExclusive != wantExclusive { continue @@ -265,6 +273,7 @@ func (t *TransferRules) addTargetID(sourceID string, targetID string) { newTargetMailboxes = append(newTargetMailboxes, *targetMailboxToAdd) } t.setRule(rule.SourceMailbox, newTargetMailboxes, rule.FromTime, rule.ToTime, []int{RuleTargetLabelColors}) + t.updateTargetSelection(sourceID, targetMailboxToAdd.IsExclusive) } func (t *TransferRules) removeTargetID(sourceID string, targetID string) { @@ -286,10 +295,14 @@ func (t *TransferRules) removeTargetID(sourceID string, targetID string) { } } t.setRule(rule.SourceMailbox, newTargetMailboxes, rule.FromTime, rule.ToTime, []int{RuleTargetLabelColors}) + t.updateTargetSelection(sourceID, targetMailboxToRemove.IsExclusive) } // Helpers +// getRule returns rule for given source ID. +// WARN: Always get new rule after change because previous pointer points to +// outdated struct with old data. func (t *TransferRules) getRule(sourceID string) *transfer.Rule { mailbox := t.getMailbox(t.transfer.SourceMailboxes, sourceID) if mailbox == nil { @@ -331,20 +344,19 @@ func (t *TransferRules) unsetRule(sourceMailbox transfer.Mailbox) { } func (t *TransferRules) ruleChanged(sourceMailbox transfer.Mailbox, changedRoles []int) { - for row, rule := range t.transfer.GetRules() { + allRules := t.transfer.GetRules() + for row, rule := range allRules { if rule.SourceMailbox.Hash() != sourceMailbox.Hash() { continue } - t.targetFolders(sourceMailbox.Hash()).itemsChanged(rule) - t.targetLabels(sourceMailbox.Hash()).itemsChanged(rule) - index := t.Index(row, 0, core.NewQModelIndex()) - if !index.IsValid() || row >= t.rowCount(index) { + if !index.IsValid() || row >= len(allRules) { log.WithField("row", row).Warning("Invalid index") return } + log.WithField("row", row).Trace("Transfer rule changed") t.DataChanged(index, index, changedRoles) break } @@ -375,3 +387,16 @@ func (t *TransferRules) updateGroupSelection() { t.SetIsLabelGroupSelected(areAllLabelsSelected) t.SetIsFolderGroupSelected(areAllFoldersSelected) } + +func (t *TransferRules) updateTargetSelection(sourceID string, updateFolderSelect bool) { + rule := t.getRule(sourceID) + if rule == nil { + return + } + + if updateFolderSelect { + t.targetFolders(rule.SourceMailbox.Hash()).itemsChanged(rule) + } else { + t.targetLabels(rule.SourceMailbox.Hash()).itemsChanged(rule) + } +} diff --git a/internal/frontend/qt-ie/ui.go b/internal/frontend/qt-ie/ui.go index c9414de8..fe2ee8bf 100644 --- a/internal/frontend/qt-ie/ui.go +++ b/internal/frontend/qt-ie/ui.go @@ -22,7 +22,6 @@ package qtie import ( "runtime" - qtcommon "github.com/ProtonMail/proton-bridge/internal/frontend/qt-common" "github.com/therecipe/qt/core" ) @@ -181,7 +180,7 @@ func (s *GoQMLInterface) SetFrontend(f *FrontendQt) { s.ConnectStartExport(f.StartExport) s.ConnectStartImport(f.StartImport) - s.ConnectCheckPathStatus(qtcommon.CheckPathStatus) + s.ConnectCheckPathStatus(CheckPathStatus) s.ConnectStartUpdate(f.StartUpdate) diff --git a/internal/frontend/qt/frontend.go b/internal/frontend/qt/frontend.go index 2748021e..0093d10e 100644 --- a/internal/frontend/qt/frontend.go +++ b/internal/frontend/qt/frontend.go @@ -43,15 +43,13 @@ import ( "github.com/ProtonMail/proton-bridge/internal/frontend/qt-common" "github.com/ProtonMail/proton-bridge/internal/frontend/types" "github.com/ProtonMail/proton-bridge/internal/preferences" + "github.com/ProtonMail/proton-bridge/internal/updates" "github.com/ProtonMail/proton-bridge/pkg/config" "github.com/ProtonMail/proton-bridge/pkg/ports" "github.com/ProtonMail/proton-bridge/pkg/useragent" - "github.com/sirupsen/logrus" - - //"github.com/ProtonMail/proton-bridge/pkg/keychain" "github.com/ProtonMail/proton-bridge/pkg/listener" "github.com/ProtonMail/proton-bridge/pkg/pmapi" - "github.com/ProtonMail/proton-bridge/pkg/updates" + "github.com/sirupsen/logrus" "github.com/kardianos/osext" "github.com/skratchdot/open-golang/open" "github.com/therecipe/qt/core" diff --git a/internal/frontend/types/types.go b/internal/frontend/types/types.go index 83d61b66..38032e9a 100644 --- a/internal/frontend/types/types.go +++ b/internal/frontend/types/types.go @@ -22,8 +22,8 @@ import ( "github.com/ProtonMail/proton-bridge/internal/bridge" "github.com/ProtonMail/proton-bridge/internal/importexport" "github.com/ProtonMail/proton-bridge/internal/transfer" + "github.com/ProtonMail/proton-bridge/internal/updates" "github.com/ProtonMail/proton-bridge/pkg/pmapi" - "github.com/ProtonMail/proton-bridge/pkg/updates" ) // PanicHandler is an interface of a type that can be used to gracefully handle panics which occur. @@ -104,7 +104,7 @@ func (b *bridgeWrap) GetUser(query string) (User, error) { return b.Bridge.GetUser(query) } -// ImportExporter is an interface of import/export needed by frontend. +// ImportExporter is an interface of import-export needed by frontend. type ImportExporter interface { UserManager @@ -121,9 +121,9 @@ type importExportWrap struct { *importexport.ImportExport } -// NewImportExportWrap wraps import/export struct into local importExportWrap +// NewImportExportWrap wraps import-export struct into local importExportWrap // to implement local interface. -// The problem is that Import/Export returns the importexport package's User +// The problem is that Import-Export returns the importexport package's User // type. Every method which returns User therefore has to be overridden to // fulfill the interface. func NewImportExportWrap(ie *importexport.ImportExport) *importExportWrap { //nolint[golint] diff --git a/internal/importexport/importexport.go b/internal/importexport/importexport.go index 0825fea9..bc813984 100644 --- a/internal/importexport/importexport.go +++ b/internal/importexport/importexport.go @@ -15,7 +15,7 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -// Package importexport provides core functionality of Import/Export app. +// Package importexport provides core functionality of Import-Export app. package importexport import ( @@ -90,7 +90,7 @@ func (ie *ImportExport) ReportFile(osType, osVersion, accountName, address strin defer c.Logout() title := "[Import-Export] report file" - description := "An import/export report from the user swam down the river." + description := "An Import-Export report from the user swam down the river." report := pmapi.ReportReq{ OS: osType, @@ -120,7 +120,7 @@ func (ie *ImportExport) GetLocalImporter(address, path string) (*transfer.Transf if err != nil { return nil, err } - return transfer.New(ie.panicHandler, newImportMetricsManager(ie), ie.config.GetTransferDir(), source, target) + return transfer.New(ie.panicHandler, newImportMetricsManager(ie), ie.config.GetLogDir(), ie.config.GetTransferDir(), source, target) } // GetRemoteImporter returns transferrer from remote IMAP to ProtonMail account. @@ -133,7 +133,7 @@ func (ie *ImportExport) GetRemoteImporter(address, username, password, host, por if err != nil { return nil, err } - return transfer.New(ie.panicHandler, newImportMetricsManager(ie), ie.config.GetTransferDir(), source, target) + return transfer.New(ie.panicHandler, newImportMetricsManager(ie), ie.config.GetLogDir(), ie.config.GetTransferDir(), source, target) } // GetEMLExporter returns transferrer from ProtonMail account to local EML structure. @@ -143,7 +143,7 @@ func (ie *ImportExport) GetEMLExporter(address, path string) (*transfer.Transfer return nil, err } target := transfer.NewEMLProvider(path) - return transfer.New(ie.panicHandler, newExportMetricsManager(ie), ie.config.GetTransferDir(), source, target) + return transfer.New(ie.panicHandler, newExportMetricsManager(ie), ie.config.GetLogDir(), ie.config.GetTransferDir(), source, target) } // GetMBOXExporter returns transferrer from ProtonMail account to local MBOX structure. @@ -153,7 +153,7 @@ func (ie *ImportExport) GetMBOXExporter(address, path string) (*transfer.Transfe return nil, err } target := transfer.NewMBOXProvider(path) - return transfer.New(ie.panicHandler, newExportMetricsManager(ie), ie.config.GetTransferDir(), source, target) + return transfer.New(ie.panicHandler, newExportMetricsManager(ie), ie.config.GetLogDir(), ie.config.GetTransferDir(), source, target) } func (ie *ImportExport) getPMAPIProvider(address string) (*transfer.PMAPIProvider, error) { diff --git a/internal/importexport/store_factory.go b/internal/importexport/store_factory.go index 9ce40b93..99f1299d 100644 --- a/internal/importexport/store_factory.go +++ b/internal/importexport/store_factory.go @@ -21,7 +21,7 @@ import ( "github.com/ProtonMail/proton-bridge/internal/store" ) -// storeFactory implements dummy factory creating no store (not needed by Import/Export). +// storeFactory implements dummy factory creating no store (not needed by Import-Export). type storeFactory struct{} // New does nothing. diff --git a/internal/importexport/types.go b/internal/importexport/types.go index da00b1a5..c1c580db 100644 --- a/internal/importexport/types.go +++ b/internal/importexport/types.go @@ -22,5 +22,6 @@ import "github.com/ProtonMail/proton-bridge/internal/users" type Configer interface { users.Configer + GetLogDir() string GetTransferDir() string } diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index a14b1154..0296472a 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -67,7 +67,7 @@ const ( Daily = Action("daily") ) -// Metrics related to import/export (transfer) process. +// Metrics related to import-export (transfer) process. const ( // Import is used to group import metrics. Import = Category("import") diff --git a/internal/transfer/mailbox.go b/internal/transfer/mailbox.go index db300a54..db6f2eac 100644 --- a/internal/transfer/mailbox.go +++ b/internal/transfer/mailbox.go @@ -25,6 +25,25 @@ import ( "github.com/ProtonMail/proton-bridge/pkg/pmapi" ) +var systemFolderMapping = map[string]string{ //nolint[gochecknoglobals] + "bin": "Trash", + "junk": "Spam", + "all": "All Mail", + "sent mail": "Sent", + "draft": "Drafts", + "important": "Starred", + // Add more translations. +} + +// LeastUsedColor is intended to return color for creating a new inbox or label +func LeastUsedColor(mailboxes []Mailbox) string { + usedColors := []string{} + for _, m := range mailboxes { + usedColors = append(usedColors, m.Color) + } + return pmapi.LeastUsedColor(usedColors) +} + // Mailbox is universal data holder of mailbox details for every provider. type Mailbox struct { ID string @@ -43,28 +62,10 @@ func (m Mailbox) Hash() string { return fmt.Sprintf("%x", sha256.Sum256([]byte(m.Name))) } -// LeastUsedColor is intended to return color for creating a new inbox or label -func LeastUsedColor(mailboxes []Mailbox) string { - usedColors := []string{} - for _, m := range mailboxes { - usedColors = append(usedColors, m.Color) - } - return pmapi.LeastUsedColor(usedColors) -} - // findMatchingMailboxes returns all matching mailboxes from `mailboxes`. -// Only one exclusive mailbox is returned. +// Only one exclusive mailbox is included. func (m Mailbox) findMatchingMailboxes(mailboxes []Mailbox) []Mailbox { - nameVariants := []string{} - if strings.Contains(m.Name, "/") || strings.Contains(m.Name, "|") { - for _, slashPart := range strings.Split(m.Name, "/") { - for _, part := range strings.Split(slashPart, "|") { - nameVariants = append(nameVariants, strings.ToLower(part)) - } - } - } - nameVariants = append(nameVariants, strings.ToLower(m.Name)) - + nameVariants := m.nameVariants() isExclusiveIncluded := false matches := []Mailbox{} for i := range nameVariants { @@ -83,3 +84,27 @@ func (m Mailbox) findMatchingMailboxes(mailboxes []Mailbox) []Mailbox { } return matches } + +// nameVariants returns all possible variants of the mailbox name. +// The best match (original name) is at the end of the slice. +// Variants are all in lower case. Examples: +// * Foo/bar -> [foo, bar, foo/bar] +// * x/Bin -> [x, trash, bin, x/bin] +// * a|b/c -> [a, b, c, a|b/c] +func (m Mailbox) nameVariants() (nameVariants []string) { + name := strings.ToLower(m.Name) + if strings.Contains(name, "/") || strings.Contains(name, "|") { + for _, slashPart := range strings.Split(name, "/") { + for _, part := range strings.Split(slashPart, "|") { + if mappedPart, ok := systemFolderMapping[part]; ok { + nameVariants = append(nameVariants, strings.ToLower(mappedPart)) + } + nameVariants = append(nameVariants, part) + } + } + } + if mappedName, ok := systemFolderMapping[name]; ok { + nameVariants = append(nameVariants, strings.ToLower(mappedName)) + } + return append(nameVariants, name) +} diff --git a/internal/transfer/mailbox_test.go b/internal/transfer/mailbox_test.go index 5865cfdc..ee1a8333 100644 --- a/internal/transfer/mailbox_test.go +++ b/internal/transfer/mailbox_test.go @@ -66,6 +66,7 @@ func TestLeastUsedColor(t *testing.T) { } r.Equal(t, "#7569d1", LeastUsedColor(mailboxes)) } + func TestFindMatchingMailboxes(t *testing.T) { mailboxes := []Mailbox{ {Name: "Inbox", IsExclusive: true}, @@ -75,6 +76,8 @@ func TestFindMatchingMailboxes(t *testing.T) { {Name: "hello/world", IsExclusive: true}, {Name: "Hello", IsExclusive: false}, {Name: "WORLD", IsExclusive: true}, + {Name: "Trash", IsExclusive: true}, + {Name: "Drafts", IsExclusive: true}, } tests := []struct { @@ -88,6 +91,10 @@ func TestFindMatchingMailboxes(t *testing.T) { {"hello/world", []string{"hello/world", "Hello"}}, {"hello|world", []string{"WORLD", "Hello"}}, {"nomailbox", []string{}}, + {"bin", []string{"Trash"}}, + {"root/bin", []string{"Trash"}}, + {"draft", []string{"Drafts"}}, + {"root/draft", []string{"Drafts"}}, } for _, tc := range tests { tc := tc diff --git a/internal/transfer/progress.go b/internal/transfer/progress.go index 0798bed5..b4873077 100644 --- a/internal/transfer/progress.go +++ b/internal/transfer/progress.go @@ -30,11 +30,12 @@ import ( // Import and export update progress about processing messages and progress // informs user interface, vice versa action (such as pause or resume) from // user interface is passed down to import and export. -type Progress struct { +type Progress struct { //nolint[maligned] log *logrus.Entry - lock sync.RWMutex + lock sync.Locker updateCh chan struct{} + messageCounted bool messageCounts map[string]uint messageStatuses map[string]*MessageStatus pauseReason string @@ -45,7 +46,8 @@ type Progress struct { func newProgress(log *logrus.Entry, fileReport *fileReport) Progress { return Progress{ - log: log, + log: log, + lock: &sync.Mutex{}, updateCh: make(chan struct{}), messageCounts: map[string]uint{}, @@ -57,11 +59,7 @@ func newProgress(log *logrus.Entry, fileReport *fileReport) Progress { // update is helper to notify listener for updates. func (p *Progress) update() { if p.updateCh == nil { - // If the progress was ended by fatal instead finish, we ignore error. - if p.fatalError != nil { - return - } - panic("update should not be called after finish was called") + return } // In case no one listens for an update, do not block the progress. @@ -71,17 +69,12 @@ func (p *Progress) update() { } } -// start should be called before anything starts. -func (p *Progress) start() { - p.lock.Lock() - defer p.lock.Unlock() -} - // finish should be called as the last call once everything is done. func (p *Progress) finish() { p.lock.Lock() defer p.lock.Unlock() + log.Debug("Progress finished") p.cleanUpdateCh() } @@ -90,6 +83,7 @@ func (p *Progress) fatal(err error) { p.lock.Lock() defer p.lock.Unlock() + log.WithError(err).Error("Progress finished") p.isStopped = true p.fatalError = err p.cleanUpdateCh() @@ -97,21 +91,26 @@ func (p *Progress) fatal(err error) { func (p *Progress) cleanUpdateCh() { if p.updateCh == nil { - // If the progress was ended by fatal instead finish, we ignore error. - if p.fatalError != nil { - return - } - panic("update should not be called after finish was called") + return } close(p.updateCh) p.updateCh = nil } +func (p *Progress) countsFinal() { + p.lock.Lock() + defer p.lock.Unlock() + defer p.update() + + log.Info("Estimating count finished") + p.messageCounted = true +} + func (p *Progress) updateCount(mailbox string, count uint) { p.lock.Lock() - defer p.update() defer p.lock.Unlock() + defer p.update() log.WithField("mailbox", mailbox).WithField("count", count).Debug("Mailbox count updated") p.messageCounts[mailbox] = count @@ -120,8 +119,8 @@ func (p *Progress) updateCount(mailbox string, count uint) { // addMessage should be called as soon as there is ID of the message. func (p *Progress) addMessage(messageID string, rule *Rule) { p.lock.Lock() - defer p.update() defer p.lock.Unlock() + defer p.update() p.log.WithField("id", messageID).Trace("Message added") p.messageStatuses[messageID] = &MessageStatus{ @@ -134,10 +133,15 @@ func (p *Progress) addMessage(messageID string, rule *Rule) { // messageExported should be called right before message is exported. func (p *Progress) messageExported(messageID string, body []byte, err error) { p.lock.Lock() - defer p.update() defer p.lock.Unlock() + defer p.update() + + log := p.log.WithField("id", messageID) + if err != nil { + log = log.WithError(err) + } + log.Debug("Message exported") - p.log.WithField("id", messageID).WithError(err).Debug("Message exported") status := p.messageStatuses[messageID] status.exportErr = err if err == nil { @@ -148,7 +152,7 @@ func (p *Progress) messageExported(messageID string, body []byte, err error) { status.bodyHash = fmt.Sprintf("%x", sha256.Sum256(body)) if header, err := getMessageHeader(body); err != nil { - p.log.WithField("id", messageID).WithError(err).Warning("Failed to parse headers for reporting") + log.WithError(err).Warning("Failed to parse headers for reporting") } else { status.setDetailsFromHeader(header) } @@ -163,10 +167,15 @@ func (p *Progress) messageExported(messageID string, body []byte, err error) { // messageImported should be called right after message is imported. func (p *Progress) messageImported(messageID, importID string, err error) { p.lock.Lock() - defer p.update() defer p.lock.Unlock() + defer p.update() + + log := p.log.WithField("id", messageID) + if err != nil { + log = log.WithError(err) + } + log.Debug("Message imported") - p.log.WithField("id", messageID).WithError(err).Debug("Message imported") p.messageStatuses[messageID].targetID = importID p.messageStatuses[messageID].importErr = err if err == nil { @@ -187,6 +196,8 @@ func (p *Progress) logMessage(messageID string) { // callWrap calls the callback and in case of problem it pause the process. // Then it waits for user action to fix it and click on continue or abort. +// Every function doing I/O should be wrapped by this function to provide +// stopping and pausing functionality. func (p *Progress) callWrap(callback func() error) { for { if p.shouldStop() { @@ -222,8 +233,8 @@ func (p *Progress) GetUpdateChannel() chan struct{} { // Pause pauses the progress. func (p *Progress) Pause(reason string) { p.lock.Lock() - defer p.update() defer p.lock.Unlock() + defer p.update() p.log.Info("Progress paused") p.pauseReason = reason @@ -232,8 +243,8 @@ func (p *Progress) Pause(reason string) { // Resume resumes the progress. func (p *Progress) Resume() { p.lock.Lock() - defer p.update() defer p.lock.Unlock() + defer p.update() p.log.Info("Progress resumed") p.pauseReason = "" @@ -258,8 +269,8 @@ func (p *Progress) PauseReason() string { // Stop stops the process. func (p *Progress) Stop() { p.lock.Lock() - defer p.update() defer p.lock.Unlock() + defer p.update() p.log.Info("Progress stopped") p.isStopped = true @@ -304,6 +315,12 @@ func (p *Progress) GetCounts() (failed, imported, exported, added, total uint) { p.lock.Lock() defer p.lock.Unlock() + // Return counts only once total is estimated or the process already + // ended (for a case when it ended quickly to report it correctly). + if p.updateCh != nil && !p.messageCounted { + return + } + // Include lost messages in the process only when transfer is done. includeMissing := p.updateCh == nil @@ -334,10 +351,10 @@ func (p *Progress) GenerateBugReport() []byte { return bugReport.getData() } -func (p *Progress) FileReport() (path string) { - if r := p.fileReport; r != nil { - path = r.path +// FileReport returns path to generated defailed file report. +func (p *Progress) FileReport() string { + if p.fileReport == nil { + return "" } - - return + return p.fileReport.path } diff --git a/internal/transfer/progress_test.go b/internal/transfer/progress_test.go index 3ec170d2..f1098d92 100644 --- a/internal/transfer/progress_test.go +++ b/internal/transfer/progress_test.go @@ -29,8 +29,6 @@ func TestProgressUpdateCount(t *testing.T) { progress := newProgress(log, nil) drainProgressUpdateChannel(&progress) - progress.start() - progress.updateCount("inbox", 10) progress.updateCount("archive", 20) progress.updateCount("inbox", 12) @@ -48,8 +46,6 @@ func TestProgressAddingMessages(t *testing.T) { progress := newProgress(log, nil) drainProgressUpdateChannel(&progress) - progress.start() - // msg1 has no problem. progress.addMessage("msg1", nil) progress.messageExported("msg1", []byte(""), nil) @@ -92,18 +88,16 @@ func TestProgressFinish(t *testing.T) { progress := newProgress(log, nil) drainProgressUpdateChannel(&progress) - progress.start() progress.finish() r.Nil(t, progress.updateCh) - r.Panics(t, func() { progress.addMessage("msg", nil) }) + r.NotPanics(t, func() { progress.addMessage("msg", nil) }) } func TestProgressFatalError(t *testing.T) { progress := newProgress(log, nil) drainProgressUpdateChannel(&progress) - progress.start() progress.fatal(errors.New("fatal error")) r.Nil(t, progress.updateCh) diff --git a/internal/transfer/provider_eml_source.go b/internal/transfer/provider_eml_source.go index 7eb949b8..925ebe21 100644 --- a/internal/transfer/provider_eml_source.go +++ b/internal/transfer/provider_eml_source.go @@ -36,6 +36,10 @@ func (p *EMLProvider) TransferTo(rules transferRules, progress *Progress, ch cha return } + if len(filePathsPerFolder) == 0 { + return + } + // This list is not filtered by time but instead going throgh each file // twice or keeping all in memory we will tell rough estimation which // will be updated during processing each file. @@ -46,6 +50,7 @@ func (p *EMLProvider) TransferTo(rules transferRules, progress *Progress, ch cha progress.updateCount(folderName, uint(len(filePaths))) } + progress.countsFinal() for folderName, filePaths := range filePathsPerFolder { // No error guaranteed by getFilePathsPerFolder. diff --git a/internal/transfer/provider_eml_target.go b/internal/transfer/provider_eml_target.go index 58f7b9ac..64e33dca 100644 --- a/internal/transfer/provider_eml_target.go +++ b/internal/transfer/provider_eml_target.go @@ -21,7 +21,6 @@ import ( "io/ioutil" "os" "path/filepath" - "strings" "github.com/hashicorp/go-multierror" ) @@ -73,7 +72,7 @@ func (p *EMLProvider) createFolders(rules transferRules) error { func (p *EMLProvider) writeFile(msg Message) error { fileName := filepath.Base(msg.ID) - if !strings.HasSuffix(fileName, ".eml") { + if filepath.Ext(fileName) != ".eml" { fileName += ".eml" } diff --git a/internal/transfer/provider_imap.go b/internal/transfer/provider_imap.go index 16e7d493..0d96c04e 100644 --- a/internal/transfer/provider_imap.go +++ b/internal/transfer/provider_imap.go @@ -58,7 +58,7 @@ func (p *IMAPProvider) ID() string { // Mailboxes returns all available folder names from root of EML files. // In case the same folder name is used more than once (for example root/a/foo // and root/b/foo), it's treated as the same folder. -func (p *IMAPProvider) Mailboxes(includEmpty, includeAllMail bool) ([]Mailbox, error) { +func (p *IMAPProvider) Mailboxes(includeEmpty, includeAllMail bool) ([]Mailbox, error) { mailboxesInfo, err := p.list() if err != nil { return nil, err @@ -73,11 +73,11 @@ func (p *IMAPProvider) Mailboxes(includEmpty, includeAllMail bool) ([]Mailbox, e break } } - if hasNoSelect || mailbox.Name == "[Gmail]" { + if hasNoSelect { continue } - if !includEmpty || true { + if !includeEmpty || true { mailboxStatus, err := p.selectIn(mailbox.Name) if err != nil { return nil, err diff --git a/internal/transfer/provider_imap_source.go b/internal/transfer/provider_imap_source.go index 82d13f1c..1894487e 100644 --- a/internal/transfer/provider_imap_source.go +++ b/internal/transfer/provider_imap_source.go @@ -72,6 +72,7 @@ func (p *IMAPProvider) loadMessageInfoMap(rules transferRules, progress *Progres res[rule.SourceMailbox.Name] = messagesInfo progress.updateCount(rule.SourceMailbox.Name, uint(len(messagesInfo))) } + progress.countsFinal() return res } @@ -109,7 +110,9 @@ func (p *IMAPProvider) loadMessagesInfo(rule *Rule, progress *Progress, uidValid return } } - id := fmt.Sprintf("%s_%d:%d", rule.SourceMailbox.Name, uidValidity, imapMessage.Uid) + id := getUniqueMessageID(rule.SourceMailbox.Name, uidValidity, imapMessage.Uid) + // We use ID as key to ensure we have every unique message only once. + // Some IMAP servers responded twice the same message... messagesInfo[id] = imapMessageInfo{ id: id, uid: imapMessage.Uid, @@ -173,6 +176,10 @@ func (p *IMAPProvider) exportMessages(rule *Rule, progress *Progress, ch chan<- items := []imap.FetchItem{imap.FetchUid, imap.FetchFlags, section.FetchItem()} processMessageCallback := func(imapMessage *imap.Message) { + if progress.shouldStop() { + return + } + id, ok := uidToID[imapMessage.Uid] // Sometimes, server sends not requested messages. @@ -217,3 +224,7 @@ func (p *IMAPProvider) exportMessage(rule *Rule, id string, imapMessage *imap.Me Targets: rule.TargetMailboxes, } } + +func getUniqueMessageID(mailboxName string, uidValidity, uid uint32) string { + return fmt.Sprintf("%s_%d:%d", mailboxName, uidValidity, uid) +} diff --git a/internal/transfer/provider_mbox_source.go b/internal/transfer/provider_mbox_source.go index 68491893..6b26e4b4 100644 --- a/internal/transfer/provider_mbox_source.go +++ b/internal/transfer/provider_mbox_source.go @@ -40,6 +40,10 @@ func (p *MBOXProvider) TransferTo(rules transferRules, progress *Progress, ch ch return } + if len(filePathsPerFolder) == 0 { + return + } + for folderName, filePaths := range filePathsPerFolder { // No error guaranteed by getFilePathsPerFolder. rule, _ := rules.getRuleBySourceMailboxName(folderName) @@ -50,6 +54,7 @@ func (p *MBOXProvider) TransferTo(rules transferRules, progress *Progress, ch ch p.updateCount(rule, progress, filePath) } } + progress.countsFinal() for folderName, filePaths := range filePathsPerFolder { // No error guaranteed by getFilePathsPerFolder. diff --git a/internal/transfer/provider_pmapi_source.go b/internal/transfer/provider_pmapi_source.go index 9a9c3e6f..cf6541fc 100644 --- a/internal/transfer/provider_pmapi_source.go +++ b/internal/transfer/provider_pmapi_source.go @@ -24,6 +24,7 @@ import ( pkgMessage "github.com/ProtonMail/proton-bridge/pkg/message" "github.com/ProtonMail/proton-bridge/pkg/pmapi" "github.com/pkg/errors" + "github.com/sirupsen/logrus" ) const pmapiListPageSize = 150 @@ -59,10 +60,11 @@ func (p *PMAPIProvider) loadCounts(rules transferRules, progress *Progress) { rule := rule progress.callWrap(func() error { _, total, err := p.listMessages(&pmapi.MessagesFilter{ - LabelID: rule.SourceMailbox.ID, - Begin: rule.FromTime, - End: rule.ToTime, - Limit: 0, + AddressID: p.addressID, + LabelID: rule.SourceMailbox.ID, + Begin: rule.FromTime, + End: rule.ToTime, + Limit: 0, }) if err != nil { log.WithError(err).Warning("Problem to load counts") @@ -72,10 +74,11 @@ func (p *PMAPIProvider) loadCounts(rules transferRules, progress *Progress) { return nil }) } + progress.countsFinal() } func (p *PMAPIProvider) transferTo(rule *Rule, progress *Progress, ch chan<- Message, skipEncryptedMessages bool) { - nextID := "" + page := 0 for { if progress.shouldStop() { break @@ -84,30 +87,33 @@ func (p *PMAPIProvider) transferTo(rule *Rule, progress *Progress, ch chan<- Mes isLastPage := true progress.callWrap(func() error { + // Would be better to filter by Begin and BeginID to be sure + // in case user deletes messages during the process, no message + // is skipped (paging is off then), but API does not support + // filtering by both mentioned fields at the same time. desc := false - pmapiMessages, count, err := p.listMessages(&pmapi.MessagesFilter{ + pmapiMessages, total, err := p.listMessages(&pmapi.MessagesFilter{ AddressID: p.addressID, LabelID: rule.SourceMailbox.ID, Begin: rule.FromTime, End: rule.ToTime, - BeginID: nextID, PageSize: pmapiListPageSize, - Page: 0, + Page: page, Sort: "ID", Desc: &desc, }) if err != nil { return err } - log.WithField("label", rule.SourceMailbox.ID).WithField("next", nextID).WithField("count", count).Debug("Listing messages") + log.WithFields(logrus.Fields{ + "label": rule.SourceMailbox.ID, + "page": page, + "total": total, + "count": len(pmapiMessages), + }).Debug("Listing messages") isLastPage = len(pmapiMessages) < pmapiListPageSize - // The first ID is the last one from the last page (= do not export twice the same one). - if nextID != "" { - pmapiMessages = pmapiMessages[1:] - } - for _, pmapiMessage := range pmapiMessages { if progress.shouldStop() { break @@ -122,9 +128,7 @@ func (p *PMAPIProvider) transferTo(rule *Rule, progress *Progress, ch chan<- Mes } } - if !isLastPage { - nextID = pmapiMessages[len(pmapiMessages)-1].ID - } + page++ return nil }) diff --git a/internal/transfer/provider_pmapi_target.go b/internal/transfer/provider_pmapi_target.go index 7e146825..40c7e92f 100644 --- a/internal/transfer/provider_pmapi_target.go +++ b/internal/transfer/provider_pmapi_target.go @@ -71,6 +71,11 @@ func (p *PMAPIProvider) TransferFrom(rules transferRules, progress *Progress, ch log.Info("Started transfer from channel to PMAPI") defer log.Info("Finished transfer from channel to PMAPI") + // Cache has to be cleared before each transfer to not contain + // old stuff from previous cancelled run. + p.importMsgReqMap = map[string]*pmapi.ImportMsgReq{} + p.importMsgReqSize = 0 + for msg := range ch { if progress.shouldStop() { break diff --git a/internal/transfer/rules.go b/internal/transfer/rules.go index 7cd067b2..bfbc572d 100644 --- a/internal/transfer/rules.go +++ b/internal/transfer/rules.go @@ -229,8 +229,8 @@ func (r *transferRules) getRule(sourceMailbox Mailbox) *Rule { return r.rules[h] } -// getRules returns all set rules. -func (r *transferRules) getRules() []*Rule { +// getSortedRules returns all set rules in order by `byRuleOrder`. +func (r *transferRules) getSortedRules() []*Rule { rules := []*Rule{} for _, rule := range r.rules { rules = append(rules, rule) diff --git a/internal/transfer/rules_test.go b/internal/transfer/rules_test.go index a1d93582..3f002279 100644 --- a/internal/transfer/rules_test.go +++ b/internal/transfer/rules_test.go @@ -239,7 +239,7 @@ func TestOrderRules(t *testing.T) { } gotMailboxNames := []string{} - for _, rule := range transferRules.getRules() { + for _, rule := range transferRules.getSortedRules() { gotMailboxNames = append(gotMailboxNames, rule.SourceMailbox.Name) } diff --git a/internal/transfer/transfer.go b/internal/transfer/transfer.go index 6d7ecf68..7ae8812b 100644 --- a/internal/transfer/transfer.go +++ b/internal/transfer/transfer.go @@ -34,10 +34,11 @@ type Transfer struct { panicHandler PanicHandler metrics MetricsManager id string - dir string + logDir string rules transferRules source SourceProvider target TargetProvider + rulesCache []*Rule sourceMboxCache []Mailbox targetMboxCache []Mailbox } @@ -47,14 +48,14 @@ type Transfer struct { // source := transfer.NewEMLProvider(...) // target := transfer.NewPMAPIProvider(...) // transfer.New(source, target, ...) -func New(panicHandler PanicHandler, metrics MetricsManager, transferDir string, source SourceProvider, target TargetProvider) (*Transfer, error) { +func New(panicHandler PanicHandler, metrics MetricsManager, logDir, rulesDir string, source SourceProvider, target TargetProvider) (*Transfer, error) { transferID := fmt.Sprintf("%x", sha256.Sum256([]byte(source.ID()+"-"+target.ID()))) - rules := loadRules(transferDir, transferID) + rules := loadRules(rulesDir, transferID) transfer := &Transfer{ panicHandler: panicHandler, metrics: metrics, id: transferID, - dir: transferDir, + logDir: logDir, rules: rules, source: source, target: target, @@ -108,16 +109,19 @@ func (t *Transfer) SetGlobalTimeLimit(fromTime, toTime int64) { // SetRule sets sourceMailbox for transfer. func (t *Transfer) SetRule(sourceMailbox Mailbox, targetMailboxes []Mailbox, fromTime, toTime int64) error { + t.rulesCache = nil return t.rules.setRule(sourceMailbox, targetMailboxes, fromTime, toTime) } // UnsetRule unsets sourceMailbox from transfer. func (t *Transfer) UnsetRule(sourceMailbox Mailbox) { + t.rulesCache = nil t.rules.unsetRule(sourceMailbox) } // ResetRules unsets all rules. func (t *Transfer) ResetRules() { + t.rulesCache = nil t.rules.reset() } @@ -128,7 +132,10 @@ func (t *Transfer) GetRule(sourceMailbox Mailbox) *Rule { // GetRules returns all set transfer rules. func (t *Transfer) GetRules() []*Rule { - return t.rules.getRules() + if t.rulesCache == nil { + t.rulesCache = t.rules.getSortedRules() + } + return t.rulesCache } // SourceMailboxes returns mailboxes available at source side. @@ -171,7 +178,7 @@ func (t *Transfer) Start() *Progress { t.metrics.Start() log := log.WithField("id", t.id) - reportFile := newFileReport(t.dir, t.id) + reportFile := newFileReport(t.logDir, t.id) progress := newProgress(log, reportFile) ch := make(chan Message) @@ -179,7 +186,6 @@ func (t *Transfer) Start() *Progress { go func() { defer t.panicHandler.HandlePanic() - progress.start() t.source.TransferTo(t.rules, &progress, ch) close(ch) }() diff --git a/pkg/updates/bridge_pubkey.gpg b/internal/updates/bridge_pubkey.gpg similarity index 100% rename from pkg/updates/bridge_pubkey.gpg rename to internal/updates/bridge_pubkey.gpg diff --git a/pkg/updates/compare_versions.go b/internal/updates/compare_versions.go similarity index 100% rename from pkg/updates/compare_versions.go rename to internal/updates/compare_versions.go diff --git a/pkg/updates/compare_versions_test.go b/internal/updates/compare_versions_test.go similarity index 100% rename from pkg/updates/compare_versions_test.go rename to internal/updates/compare_versions_test.go diff --git a/pkg/updates/downloader.go b/internal/updates/downloader.go similarity index 100% rename from pkg/updates/downloader.go rename to internal/updates/downloader.go diff --git a/pkg/updates/progress.go b/internal/updates/progress.go similarity index 100% rename from pkg/updates/progress.go rename to internal/updates/progress.go diff --git a/pkg/updates/signature.go b/internal/updates/signature.go similarity index 100% rename from pkg/updates/signature.go rename to internal/updates/signature.go diff --git a/pkg/updates/sync.go b/internal/updates/sync.go similarity index 100% rename from pkg/updates/sync.go rename to internal/updates/sync.go diff --git a/pkg/updates/sync_test.go b/internal/updates/sync_test.go similarity index 100% rename from pkg/updates/sync_test.go rename to internal/updates/sync_test.go diff --git a/pkg/updates/tar.go b/internal/updates/tar.go similarity index 100% rename from pkg/updates/tar.go rename to internal/updates/tar.go diff --git a/pkg/updates/testdata/current_version_linux.json b/internal/updates/testdata/current_version_linux.json similarity index 100% rename from pkg/updates/testdata/current_version_linux.json rename to internal/updates/testdata/current_version_linux.json diff --git a/pkg/updates/testdata/current_version_linux.json.sig b/internal/updates/testdata/current_version_linux.json.sig similarity index 100% rename from pkg/updates/testdata/current_version_linux.json.sig rename to internal/updates/testdata/current_version_linux.json.sig diff --git a/pkg/updates/updates.go b/internal/updates/updates.go similarity index 98% rename from pkg/updates/updates.go rename to internal/updates/updates.go index 07463d49..e6dcfc5b 100644 --- a/pkg/updates/updates.go +++ b/internal/updates/updates.go @@ -93,7 +93,7 @@ func NewBridge(updateTempDir string) *Updates { } } -// NewImportExport inits Updates struct for import/export. +// NewImportExport inits Updates struct for import-export. func NewImportExport(updateTempDir string) *Updates { return &Updates{ version: constants.Version, @@ -102,7 +102,7 @@ func NewImportExport(updateTempDir string) *Updates { releaseNotes: importexport.ReleaseNotes, releaseFixedBugs: importexport.ReleaseFixedBugs, updateTempDir: updateTempDir, - landingPagePath: "blog/import-export-beta/", + landingPagePath: "import-export", installerFileBaseName: "Import-Export-Installer", versionFileBaseName: "current_version_ie", updateFileBaseName: "ie_upgrade", diff --git a/pkg/updates/updates_beta.go b/internal/updates/updates_beta.go similarity index 100% rename from pkg/updates/updates_beta.go rename to internal/updates/updates_beta.go diff --git a/pkg/updates/updates_qa.go b/internal/updates/updates_qa.go similarity index 100% rename from pkg/updates/updates_qa.go rename to internal/updates/updates_qa.go diff --git a/pkg/updates/updates_test.go b/internal/updates/updates_test.go similarity index 100% rename from pkg/updates/updates_test.go rename to internal/updates/updates_test.go diff --git a/pkg/updates/version_info.go b/internal/updates/version_info.go similarity index 100% rename from pkg/updates/version_info.go rename to internal/updates/version_info.go diff --git a/internal/users/credentials/credentials.go b/internal/users/credentials/credentials.go index f6736cc7..7b67b520 100644 --- a/internal/users/credentials/credentials.go +++ b/internal/users/credentials/credentials.go @@ -34,7 +34,7 @@ const ( sep = "\x00" itemLengthBridge = 9 - itemLengthImportExport = 6 // Old format for Import/Export. + itemLengthImportExport = 6 // Old format for Import-Export. ) var ( diff --git a/internal/users/users.go b/internal/users/users.go index 95ad0402..e0c18e2d 100644 --- a/internal/users/users.go +++ b/internal/users/users.go @@ -299,12 +299,11 @@ func (u *Users) addNewUser(apiUser *pmapi.User, auth *pmapi.Auth, hashedPassphra return errors.Wrap(err, "failed to update API user") } - emails := []string{} - for _, address := range client.Addresses() { - if u.useOnlyActiveAddresses && address.Receive != pmapi.CanReceive { - continue - } - emails = append(emails, address.Email) + var emails []string //nolint[prealloc] + if u.useOnlyActiveAddresses { + emails = client.Addresses().ActiveEmails() + } else { + emails = client.Addresses().AllEmails() } if _, err = u.credStorer.Add(apiUser.ID, apiUser.Name, auth.GenToken(), hashedPassphrase, emails); err != nil { diff --git a/pkg/config/config.go b/pkg/config/config.go index f60f0969..5a6a0cf9 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -200,7 +200,7 @@ func (c *Config) GetTLSKeyPath() string { // GetDBDir returns folder for db files. func (c *Config) GetDBDir() string { - return filepath.Join(c.appDirsVersion.UserCache()) + return c.appDirsVersion.UserCache() } // GetEventsPath returns path to events file containing the last processed event IDs. @@ -228,9 +228,9 @@ func (c *Config) GetPreferencesPath() string { return filepath.Join(c.appDirsVersion.UserCache(), "prefs.json") } -// GetTransferDir returns folder for import/export rule and report files. +// GetTransferDir returns folder for import-export rules files. func (c *Config) GetTransferDir() string { - return filepath.Join(c.appDirsVersion.UserCache()) + return c.appDirsVersion.UserCache() } // GetDefaultAPIPort returns default Bridge local API port. diff --git a/pkg/message/build.go b/pkg/message/build.go index 290da24e..f5e8422a 100644 --- a/pkg/message/build.go +++ b/pkg/message/build.go @@ -39,13 +39,13 @@ type Builder struct { cl pmapi.Client msg *pmapi.Message - EncryptedToHTML bool - succDcrpt bool + EncryptedToHTML bool + successfullyDecrypted bool } // NewBuilder initiated with client and message meta info. func NewBuilder(client pmapi.Client, message *pmapi.Message) *Builder { - return &Builder{cl: client, msg: message, EncryptedToHTML: true, succDcrpt: false} + return &Builder{cl: client, msg: message, EncryptedToHTML: true, successfullyDecrypted: false} } // fetchMessage will update original PM message if successful @@ -212,7 +212,7 @@ func (bld *Builder) BuildMessage() (structure *BodyStructure, message []byte, er } // SuccessfullyDecrypted is true when message was fetched and decrypted successfully -func (bld *Builder) SuccessfullyDecrypted() bool { return bld.succDcrpt } +func (bld *Builder) SuccessfullyDecrypted() bool { return bld.successfullyDecrypted } // WriteBody decrypts PM message and writes main body section. The external PGP // message is written as is (including attachments) @@ -225,7 +225,7 @@ func (bld *Builder) WriteBody(w io.Writer) error { if err := bld.msg.Decrypt(kr); err != nil && err != openpgperrors.ErrSignatureExpired { return err } - bld.succDcrpt = true + bld.successfullyDecrypted = true if bld.msg.MIMEType != pmapi.ContentTypeMultipartMixed { // transfer encoding qp := quotedprintable.NewWriter(w) diff --git a/pkg/pmapi/addresses.go b/pkg/pmapi/addresses.go index c4841f9f..56c47e59 100644 --- a/pkg/pmapi/addresses.go +++ b/pkg/pmapi/addresses.go @@ -95,6 +95,15 @@ func (l AddressList) ByID(id string) *Address { return nil } +// AllEmails returns all emails. +func (l AddressList) AllEmails() (addresses []string) { + for _, a := range l { + addresses = append(addresses, a.Email) + } + return +} + +// ActiveEmails returns only active emails. func (l AddressList) ActiveEmails() (addresses []string) { for _, a := range l { if a.Receive == CanReceive { diff --git a/pkg/pmapi/clientmanager.go b/pkg/pmapi/clientmanager.go index a2a91e88..765c9793 100644 --- a/pkg/pmapi/clientmanager.go +++ b/pkg/pmapi/clientmanager.go @@ -452,11 +452,10 @@ func (cm *ClientManager) HandleAuth(ca ClientAuth) { if ca.Auth == nil { cm.clearToken(ca.UserID) go cm.LogoutClient(ca.UserID) - return + } else { + cm.setToken(ca.UserID, ca.Auth.GenToken(), time.Duration(ca.Auth.ExpiresIn)*time.Second) } - cm.setToken(ca.UserID, ca.Auth.GenToken(), time.Duration(ca.Auth.ExpiresIn)*time.Second) - logrus.Debug("ClientManager is forwarding auth update...") cm.authUpdates <- ca logrus.Debug("Auth update was forwarded") diff --git a/test/context/context.go b/test/context/context.go index b614625c..59cf67ad 100644 --- a/test/context/context.go +++ b/test/context/context.go @@ -106,7 +106,7 @@ func New(app string) *TestContext { // Ensure that the config is cleaned up after the test is over. ctx.addCleanupChecked(cfg.ClearData, "Cleaning bridge config data") - // Create bridge or import/export instance under test. + // Create bridge or import-export instance under test. switch app { case "bridge": ctx.withBridgeInstance() diff --git a/test/context/importexport.go b/test/context/importexport.go index 6b468d52..ed943263 100644 --- a/test/context/importexport.go +++ b/test/context/importexport.go @@ -23,19 +23,19 @@ import ( "github.com/ProtonMail/proton-bridge/pkg/listener" ) -// GetImportExport returns import/export instance. +// GetImportExport returns import-export instance. func (ctx *TestContext) GetImportExport() *importexport.ImportExport { return ctx.importExport } -// withImportExportInstance creates a import/export instance for use in the test. +// withImportExportInstance creates a import-export instance for use in the test. // TestContext has this by default once called with env variable TEST_APP=ie. func (ctx *TestContext) withImportExportInstance() { ctx.importExport = newImportExportInstance(ctx.t, ctx.cfg, ctx.credStore, ctx.listener, ctx.clientManager) ctx.users = ctx.importExport.Users } -// newImportExportInstance creates a new import/export instance configured to use the given config/credstore. +// newImportExportInstance creates a new import-export instance configured to use the given config/credstore. func newImportExportInstance( t *bddT, cfg importexport.Configer, diff --git a/test/features/ie/transfer/import_export.feature b/test/features/ie/transfer/import_export.feature index 5cd5d29e..33cd9f36 100644 --- a/test/features/ie/transfer/import_export.feature +++ b/test/features/ie/transfer/import_export.feature @@ -1,4 +1,4 @@ -Feature: Import/Export +Feature: Import-Export app Background: Given there is connected user "user" And there is "user" with mailbox "Folders/Foo" From df80e7eb270134702c68ce0756bdb10b2f60c9d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20=C5=A0koda?= Date: Mon, 17 Aug 2020 10:28:10 +0200 Subject: [PATCH 07/22] Keep Import-Export credits up to date --- .../frontend/qml/ImportExportUI/Credits.qml | 51 ++----------------- internal/frontend/qt-ie/frontend.go | 9 ++-- internal/frontend/qt-ie/ui.go | 1 + internal/frontend/types/types.go | 1 - internal/importexport/importexport.go | 3 -- utils/credits.sh | 17 ++++--- 6 files changed, 21 insertions(+), 61 deletions(-) diff --git a/internal/frontend/qml/ImportExportUI/Credits.qml b/internal/frontend/qml/ImportExportUI/Credits.qml index ef85265a..0e071757 100644 --- a/internal/frontend/qml/ImportExportUI/Credits.qml +++ b/internal/frontend/qml/ImportExportUI/Credits.qml @@ -32,61 +32,20 @@ Item { ListView { anchors.fill: parent clip: true + model: go.credits.split(";") - model: [ - "github.com/0xAX/notificator" , - "github.com/abiosoft/ishell" , - "github.com/allan-simon/go-singleinstance" , - "github.com/andybalholm/cascadia" , - "github.com/bgentry/speakeasy" , - "github.com/boltdb/bolt" , - "github.com/docker/docker-credential-helpers" , - "github.com/emersion/go-imap" , - "github.com/emersion/go-imap-appendlimit" , - "github.com/emersion/go-imap-idle" , - "github.com/emersion/go-imap-move" , - "github.com/emersion/go-imap-quota" , - "github.com/emersion/go-imap-specialuse" , - "github.com/emersion/go-smtp" , - "github.com/emersion/go-textwrapper" , - "github.com/fsnotify/fsnotify" , - "github.com/jaytaylor/html2text" , - "github.com/jhillyerd/go.enmime" , - "github.com/k0kubun/pp" , - "github.com/kardianos/osext" , - "github.com/keybase/go-keychain" , - "github.com/mattn/go-colorable" , - "github.com/pkg/browser" , - "github.com/shibukawa/localsocket" , - "github.com/shibukawa/tobubus" , - "github.com/shirou/gopsutil" , - "github.com/sirupsen/logrus" , - "github.com/skratchdot/open-golang/open" , - "github.com/therecipe/qt" , - "github.com/thomasf/systray" , - "github.com/ugorji/go/codec" , - "github.com/urfave/cli" , - "" , - "Font Awesome 4.7.0", - "" , - "The Qt Company - Qt 5.9.1 LGPLv3" , - "" , - ] - - delegate: Text { + delegate: AccessibleText { anchors.horizontalCenter: parent.horizontalCenter text: modelData color: Style.main.text + font.pointSize: Style.main.fontSize * Style.pt } footer: ButtonRounded { anchors.horizontalCenter: parent.horizontalCenter - text: "Close" - onClicked: { - root.parent.hide() - } + text: qsTr("Close", "close window") + onClicked: dialogCredits.hide() } } } } - diff --git a/internal/frontend/qt-ie/frontend.go b/internal/frontend/qt-ie/frontend.go index 32971fa7..e8a0e9a9 100644 --- a/internal/frontend/qt-ie/frontend.go +++ b/internal/frontend/qt-ie/frontend.go @@ -28,6 +28,7 @@ import ( "github.com/ProtonMail/proton-bridge/internal/events" qtcommon "github.com/ProtonMail/proton-bridge/internal/frontend/qt-common" "github.com/ProtonMail/proton-bridge/internal/frontend/types" + "github.com/ProtonMail/proton-bridge/internal/importexport" "github.com/ProtonMail/proton-bridge/internal/transfer" "github.com/ProtonMail/proton-bridge/internal/updates" "github.com/ProtonMail/proton-bridge/pkg/config" @@ -94,10 +95,6 @@ func New( ie: ie, } - // Nicer string for OS - currentOS := core.QSysInfo_PrettyProductName() - ie.SetCurrentOS(currentOS) - log.Debugf("New Qt frontend: %p", f) return f } @@ -218,6 +215,10 @@ func (f *FrontendQt) QtExecute(Procedure func(*FrontendQt) error) error { if err := Procedure(f); err != nil { return err } + + // List of used packages + f.Qml.SetCredits(importexport.Credits) + // Loop if ret := gui.QGuiApplication_Exec(); ret != 0 { //err := errors.New(errors.ErrQApplication, "Event loop ended with return value: %v", string(ret)) diff --git a/internal/frontend/qt-ie/ui.go b/internal/frontend/qt-ie/ui.go index fe2ee8bf..26029a65 100644 --- a/internal/frontend/qt-ie/ui.go +++ b/internal/frontend/qt-ie/ui.go @@ -35,6 +35,7 @@ type GoQMLInterface struct { _ string `property:"currentAddress"` _ string `property:"goos"` + _ string `property:"credits"` _ bool `property:"isFirstStart"` _ bool `property:"isRestarting"` _ bool `property:"isConnectionOK"` diff --git a/internal/frontend/types/types.go b/internal/frontend/types/types.go index 38032e9a..cf8da377 100644 --- a/internal/frontend/types/types.go +++ b/internal/frontend/types/types.go @@ -112,7 +112,6 @@ type ImportExporter interface { GetRemoteImporter(string, string, string, string, string) (*transfer.Transfer, error) GetEMLExporter(string, string) (*transfer.Transfer, error) GetMBOXExporter(string, string) (*transfer.Transfer, error) - SetCurrentOS(os string) ReportBug(osType, osVersion, description, accountName, address, emailClient string) error ReportFile(osType, osVersion, accountName, address string, logdata []byte) error } diff --git a/internal/importexport/importexport.go b/internal/importexport/importexport.go index bc813984..3cab2c92 100644 --- a/internal/importexport/importexport.go +++ b/internal/importexport/importexport.go @@ -169,6 +169,3 @@ func (ie *ImportExport) getPMAPIProvider(address string) (*transfer.PMAPIProvide return transfer.NewPMAPIProvider(ie.clientManager, user.ID(), addressID) } - -// SetCurrentOS TODO -func (ie *ImportExport) SetCurrentOS(os string) {} diff --git a/utils/credits.sh b/utils/credits.sh index 4c9ccf9f..ed265dee 100755 --- a/utils/credits.sh +++ b/utils/credits.sh @@ -23,14 +23,17 @@ PACKAGE=$1 # Vendor packages LOCKFILE=../go.mod -egrep $'^\t[^=>]*$' $LOCKFILE | sed -r 's/\t([^ ]*) v.*/\1/g' > tmp1-$PACKAGE -egrep $'^\t.*=>.*v.*$' $LOCKFILE | sed -r 's/^.*=> ([^ ]*)( v.*)?/\1/g' >> tmp1-$PACKAGE -cat tmp1-$PACKAGE | egrep -v 'therecipe/qt/internal|therecipe/env_.*_512|protontech' | sort | uniq > tmp-$PACKAGE +TEMPFILE1=$(mktemp) +TEMPFILE2=$(mktemp) + +egrep $'^\t[^=>]*$' $LOCKFILE | sed -r 's/\t([^ ]*) v.*/\1/g' > $TEMPFILE1 +egrep $'^\t.*=>.*v.*$' $LOCKFILE | sed -r 's/^.*=> ([^ ]*)( v.*)?/\1/g' >> $TEMPFILE1 +cat $TEMPFILE1 | egrep -v 'therecipe/qt/internal|therecipe/env_.*_512|protontech' | sort | uniq > $TEMPFILE2 # Add non vendor credits -echo -e "\nFont Awesome 4.7.0\n\nQt 5.13 by Qt group\n" >> tmp-$PACKAGE +echo -e "\nFont Awesome 4.7.0\n\nQt 5.13 by Qt group\n" >> $TEMPFILE2 # join lines -sed -i -e ':a' -e 'N' -e '$!ba' -e 's|\n|;|g' tmp-$PACKAGE +sed -i -e ':a' -e 'N' -e '$!ba' -e 's|\n|;|g' $TEMPFILE2 cat ../utils/license_header.txt > ../internal/$PACKAGE/credits.go -echo -e '// Code generated by '`echo $0`' at '`date`'. DO NOT EDIT.\n\npackage '$PACKAGE'\n\nconst Credits = "'$(cat tmp-$PACKAGE)'"' >> ../internal/$PACKAGE/credits.go -rm tmp1-$PACKAGE tmp-$PACKAGE +echo -e '// Code generated by '`echo $0`' at '`date`'. DO NOT EDIT.\n\npackage '$PACKAGE'\n\nconst Credits = "'$(cat $TEMPFILE2)'"' >> ../internal/$PACKAGE/credits.go +rm $TEMPFILE1 $TEMPFILE2 From f8cf4e966fd11eda0a15f411d7afe1ac6dba07ae Mon Sep 17 00:00:00 2001 From: James Houlahan Date: Thu, 20 Aug 2020 13:29:46 +0200 Subject: [PATCH 08/22] fix: double colon in window title --- internal/frontend/qml/ImportExportUI/DialogExport.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/frontend/qml/ImportExportUI/DialogExport.qml b/internal/frontend/qml/ImportExportUI/DialogExport.qml index 6ffd196d..d04d0eb1 100644 --- a/internal/frontend/qml/ImportExportUI/DialogExport.qml +++ b/internal/frontend/qml/ImportExportUI/DialogExport.qml @@ -119,7 +119,7 @@ Dialog { FileAndFolderSelect { id: outputPathInput - title: qsTr("Select location of export:", "todo") + title: qsTr("Select location of export", "todo") width : inputRow.columnWidth // stretch folder input } From 29ff8cf54bcf5fd26d784ccdd374e0356be0c088 Mon Sep 17 00:00:00 2001 From: James Houlahan Date: Thu, 20 Aug 2020 14:10:00 +0200 Subject: [PATCH 09/22] fix: double colon in window title again --- internal/frontend/qml/ImportExportUI/DialogExport.qml | 2 +- internal/frontend/qml/ProtonUI/FileAndFolderSelect.qml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/frontend/qml/ImportExportUI/DialogExport.qml b/internal/frontend/qml/ImportExportUI/DialogExport.qml index d04d0eb1..6ffd196d 100644 --- a/internal/frontend/qml/ImportExportUI/DialogExport.qml +++ b/internal/frontend/qml/ImportExportUI/DialogExport.qml @@ -119,7 +119,7 @@ Dialog { FileAndFolderSelect { id: outputPathInput - title: qsTr("Select location of export", "todo") + title: qsTr("Select location of export:", "todo") width : inputRow.columnWidth // stretch folder input } diff --git a/internal/frontend/qml/ProtonUI/FileAndFolderSelect.qml b/internal/frontend/qml/ProtonUI/FileAndFolderSelect.qml index ce73c6f4..add44f54 100644 --- a/internal/frontend/qml/ProtonUI/FileAndFolderSelect.qml +++ b/internal/frontend/qml/ProtonUI/FileAndFolderSelect.qml @@ -60,7 +60,7 @@ Row { FileDialog { id: pathDialog - title: root.title + ":" + title: root.title folder: shortcuts.home onAccepted: sanitizePath(pathDialog.fileUrl.toString()) selectFolder: true From 5f02e59fa40d565e1f03628ca11b635c4fb9af62 Mon Sep 17 00:00:00 2001 From: Michal Horejsek Date: Thu, 20 Aug 2020 14:44:08 +0200 Subject: [PATCH 10/22] Fix showing table with errors --- internal/bridge/credits.go | 4 ++-- internal/bridge/release_notes.go | 2 +- internal/frontend/qt-ie/frontend.go | 1 - internal/importexport/credits.go | 4 ++-- internal/importexport/release_notes.go | 2 +- 5 files changed, 6 insertions(+), 7 deletions(-) diff --git a/internal/bridge/credits.go b/internal/bridge/credits.go index 0efc59ab..855a7186 100644 --- a/internal/bridge/credits.go +++ b/internal/bridge/credits.go @@ -15,8 +15,8 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -// Code generated by ./credits.sh at Wed Aug 12 09:33:24 CEST 2020. DO NOT EDIT. +// Code generated by ./credits.sh at Thu Aug 20 14:25:06 CEST 2020. DO NOT EDIT. package bridge -const Credits = "github.com/0xAX/notificator;github.com/ProtonMail/bcrypt;github.com/ProtonMail/crypto;github.com/ProtonMail/docker-credential-helpers;github.com/ProtonMail/go-appdir;github.com/ProtonMail/go-apple-mobileconfig;github.com/ProtonMail/go-autostart;github.com/ProtonMail/go-imap-id;github.com/ProtonMail/go-smtp;github.com/ProtonMail/go-vcard;github.com/ProtonMail/gopenpgp/v2;github.com/abiosoft/ishell;github.com/abiosoft/readline;github.com/allan-simon/go-singleinstance;github.com/andybalholm/cascadia;github.com/certifi/gocertifi;github.com/chzyer/logex;github.com/chzyer/test;github.com/cucumber/godog;github.com/docker/docker-credential-helpers;github.com/emersion/go-imap;github.com/emersion/go-imap-appendlimit;github.com/emersion/go-imap-idle;github.com/emersion/go-imap-move;github.com/emersion/go-imap-quota;github.com/emersion/go-imap-specialuse;github.com/emersion/go-imap-unselect;github.com/emersion/go-mbox;github.com/emersion/go-message;github.com/emersion/go-sasl;github.com/emersion/go-smtp;github.com/emersion/go-textwrapper;github.com/emersion/go-vcard;github.com/fatih/color;github.com/flynn-archive/go-shlex;github.com/getsentry/raven-go;github.com/go-delve/delve;github.com/go-resty/resty/v2;github.com/golang/mock;github.com/google/go-cmp;github.com/google/uuid;github.com/gopherjs/gopherjs;github.com/hashicorp/go-multierror;github.com/jameskeane/bcrypt;github.com/jaytaylor/html2text;github.com/jhillyerd/enmime;github.com/kardianos/osext;github.com/keybase/go-keychain;github.com/logrusorgru/aurora;github.com/mattn/go-runewidth;github.com/miekg/dns;github.com/myesui/uuid;github.com/nsf/jsondiff;github.com/olekukonko/tablewriter;github.com/pkg/errors;github.com/psampaz/go-mod-outdated;github.com/sirupsen/logrus;github.com/skratchdot/open-golang;github.com/stretchr/testify;github.com/therecipe/qt;github.com/twinj/uuid;github.com/urfave/cli;go.etcd.io/bbolt;golang.org/x/crypto;golang.org/x/net;golang.org/x/text;gopkg.in/stretchr/testify.v1;;Font Awesome 4.7.0;;Qt 5.13 by Qt group;" +const Credits = "github.com/0xAX/notificator;github.com/ProtonMail/bcrypt;github.com/ProtonMail/crypto;github.com/ProtonMail/docker-credential-helpers;github.com/ProtonMail/go-appdir;github.com/ProtonMail/go-apple-mobileconfig;github.com/ProtonMail/go-autostart;github.com/ProtonMail/go-imap-id;github.com/ProtonMail/go-smtp;github.com/ProtonMail/go-vcard;github.com/ProtonMail/gopenpgp/v2;github.com/abiosoft/ishell;github.com/abiosoft/readline;github.com/allan-simon/go-singleinstance;github.com/andybalholm/cascadia;github.com/certifi/gocertifi;github.com/chzyer/logex;github.com/chzyer/test;github.com/cucumber/godog;github.com/docker/docker-credential-helpers;github.com/emersion/go-imap;github.com/emersion/go-imap-appendlimit;github.com/emersion/go-imap-idle;github.com/emersion/go-imap-move;github.com/emersion/go-imap-quota;github.com/emersion/go-imap-specialuse;github.com/emersion/go-imap-unselect;github.com/emersion/go-mbox;github.com/emersion/go-message;github.com/emersion/go-sasl;github.com/emersion/go-smtp;github.com/emersion/go-textwrapper;github.com/emersion/go-vcard;github.com/fatih/color;github.com/flynn-archive/go-shlex;github.com/getsentry/raven-go;github.com/go-delve/delve;github.com/go-resty/resty/v2;github.com/golang/mock;github.com/google/go-cmp;github.com/google/uuid;github.com/gopherjs/gopherjs;github.com/hashicorp/go-multierror;github.com/jameshoulahan/go-imap;github.com/jameskeane/bcrypt;github.com/jaytaylor/html2text;github.com/jhillyerd/enmime;github.com/kardianos/osext;github.com/keybase/go-keychain;github.com/logrusorgru/aurora;github.com/mattn/go-runewidth;github.com/miekg/dns;github.com/myesui/uuid;github.com/nsf/jsondiff;github.com/olekukonko/tablewriter;github.com/pkg/errors;github.com/psampaz/go-mod-outdated;github.com/sirupsen/logrus;github.com/skratchdot/open-golang;github.com/stretchr/testify;github.com/therecipe/qt;github.com/twinj/uuid;github.com/urfave/cli;go.etcd.io/bbolt;golang.org/x/crypto;golang.org/x/net;golang.org/x/text;gopkg.in/stretchr/testify.v1;;Font Awesome 4.7.0;;Qt 5.13 by Qt group;" diff --git a/internal/bridge/release_notes.go b/internal/bridge/release_notes.go index ab69ebed..3b882141 100644 --- a/internal/bridge/release_notes.go +++ b/internal/bridge/release_notes.go @@ -15,7 +15,7 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -// Code generated by ./release-notes.sh at 'Fri 07 Aug 2020 06:34:27 AM CEST'. DO NOT EDIT. +// Code generated by ./release-notes.sh at 'Thu Aug 20 14:25:06 CEST 2020'. DO NOT EDIT. package bridge diff --git a/internal/frontend/qt-ie/frontend.go b/internal/frontend/qt-ie/frontend.go index e8a0e9a9..d1270898 100644 --- a/internal/frontend/qt-ie/frontend.go +++ b/internal/frontend/qt-ie/frontend.go @@ -329,7 +329,6 @@ func (f *FrontendQt) setProgressManager(progress *transfer.Progress) { f.Qml.DisconnectCancelProcess() f.Qml.SetProgress(1) f.progress = nil - f.ErrorList.Progress = nil }() updates := progress.GetUpdateChannel() diff --git a/internal/importexport/credits.go b/internal/importexport/credits.go index b10c5df9..06d89a7c 100644 --- a/internal/importexport/credits.go +++ b/internal/importexport/credits.go @@ -15,8 +15,8 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -// Code generated by ./credits.sh at Fri 07 Aug 2020 06:34:27 AM CEST. DO NOT EDIT. +// Code generated by ./credits.sh at Thu Aug 20 14:25:06 CEST 2020. DO NOT EDIT. package importexport -const Credits = "github.com/0xAX/notificator;github.com/abiosoft/ishell;github.com/abiosoft/readline;github.com/allan-simon/go-singleinstance;github.com/andybalholm/cascadia;github.com/certifi/gocertifi;github.com/chzyer/logex;github.com/chzyer/test;github.com/cucumber/godog;github.com/docker/docker-credential-helpers;github.com/emersion/go-imap;github.com/emersion/go-imap-appendlimit;github.com/emersion/go-imap-idle;github.com/emersion/go-imap-move;github.com/emersion/go-imap-quota;github.com/emersion/go-imap-specialuse;github.com/emersion/go-imap-unselect;github.com/emersion/go-mbox;github.com/emersion/go-message;github.com/emersion/go-sasl;github.com/emersion/go-smtp;github.com/emersion/go-textwrapper;github.com/emersion/go-vcard;github.com/fatih/color;github.com/flynn-archive/go-shlex;github.com/getsentry/raven-go;github.com/go-delve/delve;github.com/golang/mock;github.com/google/go-cmp;github.com/gopherjs/gopherjs;github.com/go-resty/resty/v2;github.com/hashicorp/go-multierror;github.com/jameskeane/bcrypt;github.com/jaytaylor/html2text;github.com/jhillyerd/enmime;github.com/kardianos/osext;github.com/keybase/go-keychain;github.com/logrusorgru/aurora;github.com/miekg/dns;github.com/myesui/uuid;github.com/nsf/jsondiff;github.com/pkg/errors;github.com/ProtonMail/bcrypt;github.com/ProtonMail/crypto;github.com/ProtonMail/docker-credential-helpers;github.com/ProtonMail/go-appdir;github.com/ProtonMail/go-apple-mobileconfig;github.com/ProtonMail/go-autostart;github.com/ProtonMail/go-imap-id;github.com/ProtonMail/gopenpgp/v2;github.com/ProtonMail/go-smtp;github.com/ProtonMail/go-vcard;github.com/psampaz/go-mod-outdated;github.com/sirupsen/logrus;github.com/skratchdot/open-golang;github.com/stretchr/testify;github.com/therecipe/qt;github.com/twinj/uuid;github.com/urfave/cli;go.etcd.io/bbolt;golang.org/x/crypto;golang.org/x/net;golang.org/x/text;gopkg.in/stretchr/testify.v1;;Font Awesome 4.7.0;;Qt 5.13 by Qt group;" +const Credits = "github.com/0xAX/notificator;github.com/ProtonMail/bcrypt;github.com/ProtonMail/crypto;github.com/ProtonMail/docker-credential-helpers;github.com/ProtonMail/go-appdir;github.com/ProtonMail/go-apple-mobileconfig;github.com/ProtonMail/go-autostart;github.com/ProtonMail/go-imap-id;github.com/ProtonMail/go-smtp;github.com/ProtonMail/go-vcard;github.com/ProtonMail/gopenpgp/v2;github.com/abiosoft/ishell;github.com/abiosoft/readline;github.com/allan-simon/go-singleinstance;github.com/andybalholm/cascadia;github.com/certifi/gocertifi;github.com/chzyer/logex;github.com/chzyer/test;github.com/cucumber/godog;github.com/docker/docker-credential-helpers;github.com/emersion/go-imap;github.com/emersion/go-imap-appendlimit;github.com/emersion/go-imap-idle;github.com/emersion/go-imap-move;github.com/emersion/go-imap-quota;github.com/emersion/go-imap-specialuse;github.com/emersion/go-imap-unselect;github.com/emersion/go-mbox;github.com/emersion/go-message;github.com/emersion/go-sasl;github.com/emersion/go-smtp;github.com/emersion/go-textwrapper;github.com/emersion/go-vcard;github.com/fatih/color;github.com/flynn-archive/go-shlex;github.com/getsentry/raven-go;github.com/go-delve/delve;github.com/go-resty/resty/v2;github.com/golang/mock;github.com/google/go-cmp;github.com/google/uuid;github.com/gopherjs/gopherjs;github.com/hashicorp/go-multierror;github.com/jameshoulahan/go-imap;github.com/jameskeane/bcrypt;github.com/jaytaylor/html2text;github.com/jhillyerd/enmime;github.com/kardianos/osext;github.com/keybase/go-keychain;github.com/logrusorgru/aurora;github.com/mattn/go-runewidth;github.com/miekg/dns;github.com/myesui/uuid;github.com/nsf/jsondiff;github.com/olekukonko/tablewriter;github.com/pkg/errors;github.com/psampaz/go-mod-outdated;github.com/sirupsen/logrus;github.com/skratchdot/open-golang;github.com/stretchr/testify;github.com/therecipe/qt;github.com/twinj/uuid;github.com/urfave/cli;go.etcd.io/bbolt;golang.org/x/crypto;golang.org/x/net;golang.org/x/text;gopkg.in/stretchr/testify.v1;;Font Awesome 4.7.0;;Qt 5.13 by Qt group;" diff --git a/internal/importexport/release_notes.go b/internal/importexport/release_notes.go index c922d763..ef185d18 100644 --- a/internal/importexport/release_notes.go +++ b/internal/importexport/release_notes.go @@ -15,7 +15,7 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -// Code generated by ./release-notes.sh at 'Fri 07 Aug 2020 06:34:27 AM CEST'. DO NOT EDIT. +// Code generated by ./release-notes.sh at 'Thu Aug 20 14:25:06 CEST 2020'. DO NOT EDIT. package importexport From 2182e573f9677b714f42e8463df4c129c0412d54 Mon Sep 17 00:00:00 2001 From: Jakub Date: Thu, 20 Aug 2020 13:38:09 +0200 Subject: [PATCH 11/22] Update maximal date on every DateInput dropdown toggle --- .../frontend/qml/ImportExportUI/DateInput.qml | 25 +++++++++++++++---- .../frontend/qml/ImportExportUI/DateRange.qml | 1 + .../qml/ImportExportUI/InlineDateRange.qml | 1 + 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/internal/frontend/qml/ImportExportUI/DateInput.qml b/internal/frontend/qml/ImportExportUI/DateInput.qml index b9b78a9b..7292eb5d 100644 --- a/internal/frontend/qml/ImportExportUI/DateInput.qml +++ b/internal/frontend/qml/ImportExportUI/DateInput.qml @@ -33,10 +33,11 @@ Rectangle { property var dropDownStyle : Style.dropDownLight // dates - property date currentDate : new Date() // default now - property date minDate : new Date(0) // default epoch start - property date maxDate : new Date() // default now - property int unix : Math.floor(currentDate.getTime()/1000) + property date currentDate : new Date() // default now + property date minDate : new Date(0) // default epoch start + property date maxDate : new Date() // default now + property bool isMaxDateToday : false + property int unix : Math.floor(currentDate.getTime()/1000) onMinDateChanged: { if (isNaN(minDate.getTime()) || minDate.getTime() > maxDate.getTime()) { @@ -103,6 +104,11 @@ Rectangle { onActivated: updateRange() anchors.verticalCenter: parent.verticalCenter dropDownStyle: root.dropDownStyle + onDownChanged: { + if (root.isMaxDateToday){ + root.maxDate = new Date() + } + } } Rectangle { @@ -120,6 +126,11 @@ Rectangle { onActivated: updateRange() anchors.verticalCenter: parent.verticalCenter dropDownStyle: root.dropDownStyle + onDownChanged: { + if (root.isMaxDateToday){ + root.maxDate = new Date() + } + } } Rectangle { @@ -136,6 +147,11 @@ Rectangle { onActivated: updateRange() anchors.verticalCenter: parent.verticalCenter dropDownStyle: root.dropDownStyle + onDownChanged: { + if (root.isMaxDateToday){ + root.maxDate = new Date() + } + } } } @@ -240,4 +256,3 @@ Rectangle { )) } } - diff --git a/internal/frontend/qml/ImportExportUI/DateRange.qml b/internal/frontend/qml/ImportExportUI/DateRange.qml index d1decff5..4e6339a3 100644 --- a/internal/frontend/qml/ImportExportUI/DateRange.qml +++ b/internal/frontend/qml/ImportExportUI/DateRange.qml @@ -63,6 +63,7 @@ Column { metricsLabel: inputDateFrom.label currentDate: new Date() // now minDate: inputDateFrom.currentDate + isMaxDateToday: true dropDownStyle: dateRange.dropDownStyle } diff --git a/internal/frontend/qml/ImportExportUI/InlineDateRange.qml b/internal/frontend/qml/ImportExportUI/InlineDateRange.qml index 8518048e..3a071941 100644 --- a/internal/frontend/qml/ImportExportUI/InlineDateRange.qml +++ b/internal/frontend/qml/ImportExportUI/InlineDateRange.qml @@ -76,6 +76,7 @@ Row { anchors.verticalCenter: parent.verticalCenter currentDate: new Date() // default now minDate: inputDateFrom.currentDate + isMaxDateToday: true } CheckBoxLabel { From 71b9a3b205f72c51882196fa930bfbb7da2699e4 Mon Sep 17 00:00:00 2001 From: Michal Horejsek Date: Thu, 20 Aug 2020 15:29:39 +0200 Subject: [PATCH 12/22] Release notes for Import-Export --- release-notes/bugs-importexport.txt | 4 ++++ release-notes/notes-importexport.txt | 3 +++ 2 files changed, 7 insertions(+) diff --git a/release-notes/bugs-importexport.txt b/release-notes/bugs-importexport.txt index e69de29b..4449315b 100644 --- a/release-notes/bugs-importexport.txt +++ b/release-notes/bugs-importexport.txt @@ -0,0 +1,4 @@ +• Fixed rare cases where the application freezes when starting/stopping imports +• Allowed current date to be included in the selected date range for both import and export +• Improved manual update process +• Limit space usage by on device application logs diff --git a/release-notes/notes-importexport.txt b/release-notes/notes-importexport.txt index e69de29b..9d2dbdc2 100644 --- a/release-notes/notes-importexport.txt +++ b/release-notes/notes-importexport.txt @@ -0,0 +1,3 @@ +• Complete code refactor in preparation of live release and open source of the Import-Export app +• Increased number of supported mail providers by changing the way the folder structures are handled (NIL hierarchy delimiter) +• Improved handling for unstable internet and pause & resume behavior From 40aeb6c010540e872ff8b85922f37cf371331b8b Mon Sep 17 00:00:00 2001 From: Jakub Date: Thu, 20 Aug 2020 16:09:47 +0200 Subject: [PATCH 13/22] Fixing IE icon --- Makefile | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/Makefile b/Makefile index 4407c70c..3bd81435 100644 --- a/Makefile +++ b/Makefile @@ -11,9 +11,17 @@ TARGET_OS?=${GOOS} BRIDGE_APP_VERSION?=1.4.0-git IE_APP_VERSION?=1.0.0-git -APP_VERSION=${BRIDGE_APP_VERSION} +APP_VERSION:=${BRIDGE_APP_VERSION} +SRC_ICO:=logo.ico +SRC_ICNS:=Bridge.icns +SRC_SVG:=logo.svg +TGT_ICNS:=Bridge.icns ifeq "${TARGET_CMD}" "Import-Export" - APP_VERSION=${IE_APP_VERSION} + APP_VERSION:=${IE_APP_VERSION} + SRC_ICO:=ie.ico + SRC_ICNS:=ie.icns + SRC_SVG:=ie.svg + TGT_ICNS:=ImportExport.icns endif REVISION:=$(shell git rev-parse --short=10 HEAD) BUILD_TIME:=$(shell date +%FT%T%z) @@ -35,7 +43,7 @@ EXE:=$(shell basename ${CURDIR}) ifeq "${TARGET_OS}" "windows" EXE:=${EXE}.exe - ICO_FILES:=logo.ico icon.rc icon_windows.syso + ICO_FILES:=${SRC_ICO} icon.rc icon_windows.syso endif ifeq "${TARGET_OS}" "darwin" DARWINAPP_CONTENTS:=${DEPLOY_DIR}/darwin/${EXE}.app/Contents @@ -64,12 +72,12 @@ ${TGZ_TARGET}: ${DEPLOY_DIR}/${TARGET_OS} cd ${DEPLOY_DIR} && tar czf ../../../$@ ${TARGET_OS} ${DEPLOY_DIR}/linux: ${EXE_TARGET} - cp -pf ./internal/frontend/share/icons/logo.svg ${DEPLOY_DIR}/linux/ + cp -pf ./internal/frontend/share/icons/${SRC_SVG} ${DEPLOY_DIR}/linux/logo.svg cp -pf ./LICENSE ${DEPLOY_DIR}/linux/ cp -pf ./Changelog.md ${DEPLOY_DIR}/linux/ ${DEPLOY_DIR}/darwin: ${EXE_TARGET} - cp ./internal/frontend/share/icons/Bridge.icns ${DARWINAPP_CONTENTS}/Resources/ + cp ./internal/frontend/share/icons/${SRC_ICNS} ${DARWINAPP_CONTENTS}/Resources/${TGT_ICNS} cp LICENSE ${DARWINAPP_CONTENTS}/Resources/ rm -rf "${DARWINAPP_CONTENTS}/Frameworks/QtWebEngine.framework" rm -rf "${DARWINAPP_CONTENTS}/Frameworks/QtWebView.framework" @@ -77,7 +85,7 @@ ${DEPLOY_DIR}/darwin: ${EXE_TARGET} ./utils/remove_non_relative_links_darwin.sh "${EXE_TARGET}" ${DEPLOY_DIR}/windows: ${EXE_TARGET} - cp ./internal/frontend/share/icons/logo.ico ${DEPLOY_DIR}/windows/ + cp ./internal/frontend/share/icons/${SRC_ICO} ${DEPLOY_DIR}/windows/logo.ico cp LICENSE ${DEPLOY_DIR}/windows/ QT_BUILD_TARGET:=build desktop @@ -94,14 +102,12 @@ ${EXE_TARGET}: check-has-go gofiles ${ICO_FILES} update-vendor mv deploy cmd/${TARGET_CMD} rm -rf ${TARGET_OS} main.go -logo.ico: ./internal/frontend/share/icons/logo.ico - cp $^ . +logo.ico ie.ico: ./internal/frontend/share/icons/${SRC_ICO} + cp $^ $@ icon.rc: ./internal/frontend/share/icon.rc cp $^ . -./internal/frontend/qt/icon_windows.syso: ./internal/frontend/share/icon.rc logo.ico +icon_windows.syso: icon.rc logo.ico windres --target=pe-x86-64 -o $@ $< -icon_windows.syso: ./internal/frontend/qt/icon_windows.syso - cp $^ . ## Rules for therecipe/qt From 8592a264c04514c068c41b33bce5b892457a5ed7 Mon Sep 17 00:00:00 2001 From: Michal Horejsek Date: Fri, 21 Aug 2020 10:01:45 +0200 Subject: [PATCH 14/22] Fix showing error msg --- internal/frontend/qml/GuiIE.qml | 5 +++-- internal/frontend/qt-ie/frontend.go | 1 + internal/importexport/release_notes.go | 11 ++++++++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/internal/frontend/qml/GuiIE.qml b/internal/frontend/qml/GuiIE.qml index bac8918e..638b1898 100644 --- a/internal/frontend/qml/GuiIE.qml +++ b/internal/frontend/qml/GuiIE.qml @@ -137,8 +137,9 @@ Item { } onNotifyError : { - var name = go.errorDescription.slice(0, go.errorDescription.indexOf("\n")) - var errorMessage = go.errorDescription.slice(go.errorDescription.indexOf("\n")) + var sep = go.errorDescription.indexOf("\n") < 0 ? go.errorDescription.length : go.errorDescription.indexOf("\n") + var name = go.errorDescription.slice(0, sep) + var errorMessage = go.errorDescription.slice(sep) switch (errCode) { case gui.enums.errPMLoadFailed : winMain.popupMessage.show ( qsTr ( "Loading ProtonMail folders and labels was not successful." , "Error message" ) ) diff --git a/internal/frontend/qt-ie/frontend.go b/internal/frontend/qt-ie/frontend.go index d1270898..c45b3f0e 100644 --- a/internal/frontend/qt-ie/frontend.go +++ b/internal/frontend/qt-ie/frontend.go @@ -474,6 +474,7 @@ func (f *FrontendQt) createLabelOrFolder(email, name, color string, isLabel bool m, err := f.transfer.CreateTargetMailbox(m) if err != nil { log.Errorln("Folder/Label creating:", err) + err = errors.New(name + "\n" + err.Error()) // GUI splits by \n. if isLabel { f.showError(errCreateLabelFailed, err) } else { diff --git a/internal/importexport/release_notes.go b/internal/importexport/release_notes.go index ef185d18..7a68c97c 100644 --- a/internal/importexport/release_notes.go +++ b/internal/importexport/release_notes.go @@ -15,12 +15,17 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -// Code generated by ./release-notes.sh at 'Thu Aug 20 14:25:06 CEST 2020'. DO NOT EDIT. +// Code generated by ./release-notes.sh at 'Fri Aug 21 09:35:33 CEST 2020'. DO NOT EDIT. package importexport -const ReleaseNotes = ` +const ReleaseNotes = `• Complete code refactor in preparation of live release and open source of the Import-Export app +• Increased number of supported mail providers by changing the way the folder structures are handled (NIL hierarchy delimiter) +• Improved handling for unstable internet and pause & resume behavior ` -const ReleaseFixedBugs = ` +const ReleaseFixedBugs = `• Fixed rare cases where the application freezes when starting/stopping imports +• Allowed current date to be included in the selected date range for both import and export +• Improved manual update process +• Limit space usage by on device application logs ` From e4704cd4594851574c5306faaf52831113704bae Mon Sep 17 00:00:00 2001 From: Michal Horejsek Date: Fri, 21 Aug 2020 10:56:59 +0200 Subject: [PATCH 15/22] Release notes --- internal/bridge/credits.go | 2 +- internal/frontend/qml/ImportExportUI/VersionInfo.qml | 10 ++++++++++ internal/frontend/qt-ie/frontend.go | 1 + internal/frontend/qt-ie/ui.go | 1 + internal/importexport/credits.go | 2 +- 5 files changed, 14 insertions(+), 2 deletions(-) diff --git a/internal/bridge/credits.go b/internal/bridge/credits.go index 855a7186..73fae98a 100644 --- a/internal/bridge/credits.go +++ b/internal/bridge/credits.go @@ -15,7 +15,7 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -// Code generated by ./credits.sh at Thu Aug 20 14:25:06 CEST 2020. DO NOT EDIT. +// Code generated by ./credits.sh at Fri Aug 21 10:29:43 CEST 2020. DO NOT EDIT. package bridge diff --git a/internal/frontend/qml/ImportExportUI/VersionInfo.qml b/internal/frontend/qml/ImportExportUI/VersionInfo.qml index ad90488d..eeb38873 100644 --- a/internal/frontend/qml/ImportExportUI/VersionInfo.qml +++ b/internal/frontend/qml/ImportExportUI/VersionInfo.qml @@ -108,6 +108,16 @@ Item { root.parent.hide() } } + + + AccessibleSelectableText { + anchors.horizontalCenter: content.horizontalCenter + font { + pointSize : Style.main.fontSize * Style.pt + } + color: Style.main.textDisabled + text: "\n Current: "+go.fullversion + } } } } diff --git a/internal/frontend/qt-ie/frontend.go b/internal/frontend/qt-ie/frontend.go index c45b3f0e..e34e7468 100644 --- a/internal/frontend/qt-ie/frontend.go +++ b/internal/frontend/qt-ie/frontend.go @@ -218,6 +218,7 @@ func (f *FrontendQt) QtExecute(Procedure func(*FrontendQt) error) error { // List of used packages f.Qml.SetCredits(importexport.Credits) + f.Qml.SetFullversion(f.buildVersion) // Loop if ret := gui.QGuiApplication_Exec(); ret != 0 { diff --git a/internal/frontend/qt-ie/ui.go b/internal/frontend/qt-ie/ui.go index 26029a65..1ae5733a 100644 --- a/internal/frontend/qt-ie/ui.go +++ b/internal/frontend/qt-ie/ui.go @@ -49,6 +49,7 @@ type GoQMLInterface struct { _ string `property:"programTitle"` _ string `property:"newversion"` + _ string `property:"fullversion"` _ string `property:"downloadLink"` _ string `property:"landingPage"` _ string `property:"changelog"` diff --git a/internal/importexport/credits.go b/internal/importexport/credits.go index 06d89a7c..60465343 100644 --- a/internal/importexport/credits.go +++ b/internal/importexport/credits.go @@ -15,7 +15,7 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -// Code generated by ./credits.sh at Thu Aug 20 14:25:06 CEST 2020. DO NOT EDIT. +// Code generated by ./credits.sh at Fri Aug 21 10:29:44 CEST 2020. DO NOT EDIT. package importexport From 4973e387487114948769d38a907766278181c9d9 Mon Sep 17 00:00:00 2001 From: Michal Horejsek Date: Fri, 21 Aug 2020 11:41:11 +0200 Subject: [PATCH 16/22] Import-Export app everywhere --- README.md | 2 +- cmd/Import-Export/main.go | 2 +- doc/importexport.md | 4 ++-- doc/index.md | 2 +- internal/frontend/cli-ie/frontend.go | 2 +- internal/frontend/cli-ie/system.go | 2 +- internal/frontend/cli-ie/updates.go | 2 +- internal/frontend/cli-ie/utils.go | 2 +- internal/frontend/qml/GuiIE.qml | 2 +- internal/frontend/qml/ImportExportUI/DialogYesNo.qml | 2 +- internal/frontend/qml/ImportExportUI/HelpView.qml | 2 +- 11 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index b9d90831..f2bfcd01 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# ProtonMail Bridge and Import Export +# ProtonMail Bridge and Import Export app Copyright (c) 2020 Proton Technologies AG This repository holds the ProtonMail Bridge application. diff --git a/cmd/Import-Export/main.go b/cmd/Import-Export/main.go index a2d02cbb..1a45b6f4 100644 --- a/cmd/Import-Export/main.go +++ b/cmd/Import-Export/main.go @@ -65,7 +65,7 @@ func run(context *cli.Context) (contextError error) { // nolint[funlen] // report which will not be possible if no folder can be created. That's the // only problem we will not be notified about in any way. panicHandler := &cmd.PanicHandler{ - AppName: "ProtonMail Import-Export", + AppName: "ProtonMail Import-Export app", Config: cfg, Err: &contextError, } diff --git a/doc/importexport.md b/doc/importexport.md index 8dd2303b..c50a989e 100644 --- a/doc/importexport.md +++ b/doc/importexport.md @@ -1,8 +1,8 @@ -# Import-Export +# Import-Export app ## Main blocks -This is basic overview of the main import-export blocks. +This is basic overview of the main Import-Export blocks. ```mermaid graph LR diff --git a/doc/index.md b/doc/index.md index 4791f34a..66da2f5f 100644 --- a/doc/index.md +++ b/doc/index.md @@ -9,6 +9,6 @@ Documentation pages in order to read for a novice: * [Communication between Bridge, Client and Server](communication.md) * [Encryption](encryption.md) -## Import-Export +## Import-Export app * [Import-Export code](importexport.md) diff --git a/internal/frontend/cli-ie/frontend.go b/internal/frontend/cli-ie/frontend.go index 3388a679..ca3bb249 100644 --- a/internal/frontend/cli-ie/frontend.go +++ b/internal/frontend/cli-ie/frontend.go @@ -228,7 +228,7 @@ func (f *frontendCLI) Loop(credentialsError error) error { } f.Print(` -Welcome to ProtonMail Import-Export interactive shell +Welcome to ProtonMail Import-Export app interactive shell WARNING: The CLI is an experimental feature and does not yet cover all functionality. `) diff --git a/internal/frontend/cli-ie/system.go b/internal/frontend/cli-ie/system.go index 103b4511..86bb2add 100644 --- a/internal/frontend/cli-ie/system.go +++ b/internal/frontend/cli-ie/system.go @@ -22,7 +22,7 @@ import ( ) func (f *frontendCLI) restart(c *ishell.Context) { - if f.yesNoQuestion("Are you sure you want to restart the Import-Export") { + if f.yesNoQuestion("Are you sure you want to restart the Import-Export app") { f.Println("Restarting the Import-Export app...") f.appRestart = true f.Stop() diff --git a/internal/frontend/cli-ie/updates.go b/internal/frontend/cli-ie/updates.go index cabebd79..ea470ece 100644 --- a/internal/frontend/cli-ie/updates.go +++ b/internal/frontend/cli-ie/updates.go @@ -47,7 +47,7 @@ func (f *frontendCLI) printLocalReleaseNotes(c *ishell.Context) { } func (f *frontendCLI) printReleaseNotes(versionInfo updates.VersionInfo) { - f.Println(bold("ProtonMail Import-Export "+versionInfo.Version), "\n") + f.Println(bold("ProtonMail Import-Export app "+versionInfo.Version), "\n") if versionInfo.ReleaseNotes != "" { f.Println(bold("Release Notes")) f.Println(versionInfo.ReleaseNotes) diff --git a/internal/frontend/cli-ie/utils.go b/internal/frontend/cli-ie/utils.go index b2de679a..2a3cb934 100644 --- a/internal/frontend/cli-ie/utils.go +++ b/internal/frontend/cli-ie/utils.go @@ -98,7 +98,7 @@ func (f *frontendCLI) notifyNeedUpgrade() { func (f *frontendCLI) notifyCredentialsError() { // Print in 80-column width. - f.Println("ProtonMail Import-Export is not able to detect a supported password manager") + f.Println("ProtonMail Import-Export app is not able to detect a supported password manager") f.Println("(pass, gnome-keyring). Please install and set up a supported password manager") f.Println("and restart the application.") } diff --git a/internal/frontend/qml/GuiIE.qml b/internal/frontend/qml/GuiIE.qml index 638b1898..ff88d509 100644 --- a/internal/frontend/qml/GuiIE.qml +++ b/internal/frontend/qml/GuiIE.qml @@ -234,7 +234,7 @@ Item { } onNotifyLogout : { - go.notifyBubble(0, qsTr("Account %1 has been disconnected. Please log in to continue to use the Import-Export with this account.").arg(accname) ) + go.notifyBubble(0, qsTr("Account %1 has been disconnected. Please log in to continue to use the Import-Export app with this account.").arg(accname) ) } onNotifyAddressChanged : { diff --git a/internal/frontend/qml/ImportExportUI/DialogYesNo.qml b/internal/frontend/qml/ImportExportUI/DialogYesNo.qml index 94d6d0a1..fa6e27d2 100644 --- a/internal/frontend/qml/ImportExportUI/DialogYesNo.qml +++ b/internal/frontend/qml/ImportExportUI/DialogYesNo.qml @@ -218,7 +218,7 @@ Dialog { currentIndex : 0 title : qsTr("Clear keychain", "title of page that displays during keychain clearing") question : qsTr("Are you sure you want to clear your keychain?", "displays during keychain clearing") - note : qsTr("This will remove all accounts that you have added to the Import-Export tool.", "displays during keychain clearing") + note : qsTr("This will remove all accounts that you have added to the Import-Export app.", "displays during keychain clearing") answer : qsTr("Clearing the keychain ...", "displays during keychain clearing") } }, diff --git a/internal/frontend/qml/ImportExportUI/HelpView.qml b/internal/frontend/qml/ImportExportUI/HelpView.qml index 22e9e39d..4245388b 100644 --- a/internal/frontend/qml/ImportExportUI/HelpView.qml +++ b/internal/frontend/qml/ImportExportUI/HelpView.qml @@ -83,7 +83,7 @@ Item { color: Style.main.textDisabled horizontalAlignment: Qt.AlignHCenter font.family : Style.fontawesome.name - text: "ProtonMail Import-Export Version "+go.getBackendVersion()+"\n"+Style.fa.copyright + " 2020 Proton Technologies AG" + text: "ProtonMail Import-Export app Version "+go.getBackendVersion()+"\n"+Style.fa.copyright + " 2020 Proton Technologies AG" } } From 2d9417d501e49ba7fd805809139c3f6856d29a14 Mon Sep 17 00:00:00 2001 From: Michal Horejsek Date: Fri, 21 Aug 2020 14:27:31 +0200 Subject: [PATCH 17/22] Migrate from old credentials --- internal/importexport/release_notes.go | 5 +++-- internal/users/credentials/store.go | 7 +++++++ internal/users/users.go | 8 ++++++++ release-notes/notes-importexport.txt | 1 + 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/internal/importexport/release_notes.go b/internal/importexport/release_notes.go index 7a68c97c..52583fb6 100644 --- a/internal/importexport/release_notes.go +++ b/internal/importexport/release_notes.go @@ -15,11 +15,12 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -// Code generated by ./release-notes.sh at 'Fri Aug 21 09:35:33 CEST 2020'. DO NOT EDIT. +// Code generated by ./release-notes.sh at 'Fri Aug 21 14:17:14 CEST 2020'. DO NOT EDIT. package importexport -const ReleaseNotes = `• Complete code refactor in preparation of live release and open source of the Import-Export app +const ReleaseNotes = `• Note: you need to log in again +• Complete code refactor in preparation of live release and open source of the Import-Export app • Increased number of supported mail providers by changing the way the folder structures are handled (NIL hierarchy delimiter) • Improved handling for unstable internet and pause & resume behavior ` diff --git a/internal/users/credentials/store.go b/internal/users/credentials/store.go index e2824e0c..8341fa15 100644 --- a/internal/users/credentials/store.go +++ b/internal/users/credentials/store.go @@ -186,6 +186,13 @@ func (s *Store) List() (userIDs []string, err error) { continue } + // Old credentials using username as a key does not work with new code. + // We need to ask user to login again to get ID from API and migrate creds. + if creds.UserID == creds.Name && creds.APIToken != "" { + creds.Logout() + _ = s.saveCredentials(creds) + } + credentialList = append(credentialList, creds) } diff --git a/internal/users/users.go b/internal/users/users.go index e0c18e2d..62c6cf20 100644 --- a/internal/users/users.go +++ b/internal/users/users.go @@ -249,6 +249,14 @@ func (u *Users) FinishLogin(authClient pmapi.Client, auth *pmapi.Auth, mbPassphr } } + // Old credentials use username as key (user ID) which needs to be removed + // once user logs in again with proper ID fetched from API. + if _, ok := u.hasUser(apiUser.Name); ok { + if err := u.DeleteUser(apiUser.Name, true); err != nil { + log.WithError(err).Error("Failed to delete old user") + } + } + u.events.Emit(events.UserRefreshEvent, apiUser.ID) return u.GetUser(apiUser.ID) diff --git a/release-notes/notes-importexport.txt b/release-notes/notes-importexport.txt index 9d2dbdc2..d4b2a758 100644 --- a/release-notes/notes-importexport.txt +++ b/release-notes/notes-importexport.txt @@ -1,3 +1,4 @@ +• Note: you need to log in again • Complete code refactor in preparation of live release and open source of the Import-Export app • Increased number of supported mail providers by changing the way the folder structures are handled (NIL hierarchy delimiter) • Improved handling for unstable internet and pause & resume behavior From 61867fbde7431b96da839796cbe0a6b77642eb1a Mon Sep 17 00:00:00 2001 From: Jakub Date: Sun, 23 Aug 2020 10:04:52 +0200 Subject: [PATCH 18/22] Add hour when days don't match GODT-655 --- Changelog.md | 3 +++ internal/frontend/qml/ImportExportUI/DateInput.qml | 10 ++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Changelog.md b/Changelog.md index 5fd820b2..54f469a8 100644 --- a/Changelog.md +++ b/Changelog.md @@ -4,6 +4,8 @@ Changelog [format](http://keepachangelog.com/en/1.0.0/) ## Unreleased +## [IE 0.2.x] Congo + ### Added * GODT-633 Persistent anonymous API cookies for better load balancing and abuse detection. @@ -49,6 +51,7 @@ Changelog [format](http://keepachangelog.com/en/1.0.0/) * Structure for transfer rules in QML ### Fixed +* GODT-655 Fix date picker with automatic Windows DST * GODT-454 Fix send on closed channel when receiving unencrypted send confirmation from GUI. * GODT-597 Duplicate sending when draft creation takes too long diff --git a/internal/frontend/qml/ImportExportUI/DateInput.qml b/internal/frontend/qml/ImportExportUI/DateInput.qml index 7292eb5d..b88a16bb 100644 --- a/internal/frontend/qml/ImportExportUI/DateInput.qml +++ b/internal/frontend/qml/ImportExportUI/DateInput.qml @@ -247,8 +247,14 @@ Rectangle { if (!isNaN(parseInt(dayInput.currentText))) { day = Math.min(day, parseInt(dayInput.currentText)) } - currentString = [ yearInput.currentText, monthInput.currentText, day].join("-") - currentUnix = Date.fromLocaleDateString( locale, currentString, "yyyy-MMM-d").getTime() + var month = gui.allMonths.indexOf(monthInput.currentText) + var year = parseInt(yearInput.currentText) + var pickedDate = new Date(year, month, day) + // Compensate automatic DST in windows + if (pickedDate.getDate() != day) { + pickedDate.setTime(pickedDate.getTime() + 60*60*1000) // add hour + } + currentUnix = pickedDate.getTime() } return new Date(Math.max( minDate.getTime(), From 1d2e584799e5e31880a32b5f6864c9a1ad189652 Mon Sep 17 00:00:00 2001 From: Michal Horejsek Date: Mon, 31 Aug 2020 15:57:45 +0200 Subject: [PATCH 19/22] Convert panics from message parser to error --- Changelog.md | 2 +- internal/bridge/credits.go | 2 +- internal/bridge/release_notes.go | 2 +- internal/frontend/cli-ie/importexport.go | 4 +++- internal/importexport/credits.go | 2 +- internal/importexport/release_notes.go | 2 +- internal/transfer/provider_pmapi_target.go | 9 ++++++++- 7 files changed, 16 insertions(+), 7 deletions(-) diff --git a/Changelog.md b/Changelog.md index 54f469a8..293eaf3a 100644 --- a/Changelog.md +++ b/Changelog.md @@ -36,7 +36,6 @@ Changelog [format](http://keepachangelog.com/en/1.0.0/) * golang.org/x/text v0.3.2 -> v0.3.3 * Set first-start to false in bridge, not in frontend. * GODT-400 Refactor sendingInfo. - * GODT-380 Adding IE GUI to Bridge repo and building * BR: extend functionality of PopupDialog * BR: makefile APP_VERSION instead of BRIDGE_VERSION @@ -49,6 +48,7 @@ Changelog [format](http://keepachangelog.com/en/1.0.0/) * IE: Added event watch in GUI * IE: Removed `onLoginFinished` * Structure for transfer rules in QML +* GODT-213 Convert panics from message parser to error. ### Fixed * GODT-655 Fix date picker with automatic Windows DST diff --git a/internal/bridge/credits.go b/internal/bridge/credits.go index 73fae98a..9df2931e 100644 --- a/internal/bridge/credits.go +++ b/internal/bridge/credits.go @@ -15,7 +15,7 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -// Code generated by ./credits.sh at Fri Aug 21 10:29:43 CEST 2020. DO NOT EDIT. +// Code generated by ./credits.sh at Mon Aug 31 15:08:14 CEST 2020. DO NOT EDIT. package bridge diff --git a/internal/bridge/release_notes.go b/internal/bridge/release_notes.go index 3b882141..5b7673fd 100644 --- a/internal/bridge/release_notes.go +++ b/internal/bridge/release_notes.go @@ -15,7 +15,7 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -// Code generated by ./release-notes.sh at 'Thu Aug 20 14:25:06 CEST 2020'. DO NOT EDIT. +// Code generated by ./release-notes.sh at 'Mon Aug 31 15:08:14 CEST 2020'. DO NOT EDIT. package bridge diff --git a/internal/frontend/cli-ie/importexport.go b/internal/frontend/cli-ie/importexport.go index fe89d7df..ed63df34 100644 --- a/internal/frontend/cli-ie/importexport.go +++ b/internal/frontend/cli-ie/importexport.go @@ -185,7 +185,9 @@ func (f *frontendCLI) setTransferRules(t *transfer.Transfer) bool { func (f *frontendCLI) printTransferProgress(progress *transfer.Progress) { failed, imported, exported, added, total := progress.GetCounts() - f.Println(fmt.Sprintf("Progress update: %d (%d / %d) / %d, failed: %d", imported, exported, added, total, failed)) + if total != 0 { + f.Println(fmt.Sprintf("Progress update: %d (%d / %d) / %d, failed: %d", imported, exported, added, total, failed)) + } if progress.IsPaused() { f.Printf("Transfer is paused bacause %s", progress.PauseReason()) diff --git a/internal/importexport/credits.go b/internal/importexport/credits.go index 60465343..6907b3cb 100644 --- a/internal/importexport/credits.go +++ b/internal/importexport/credits.go @@ -15,7 +15,7 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -// Code generated by ./credits.sh at Fri Aug 21 10:29:44 CEST 2020. DO NOT EDIT. +// Code generated by ./credits.sh at Mon Aug 31 15:08:14 CEST 2020. DO NOT EDIT. package importexport diff --git a/internal/importexport/release_notes.go b/internal/importexport/release_notes.go index 52583fb6..14c883f2 100644 --- a/internal/importexport/release_notes.go +++ b/internal/importexport/release_notes.go @@ -15,7 +15,7 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -// Code generated by ./release-notes.sh at 'Fri Aug 21 14:17:14 CEST 2020'. DO NOT EDIT. +// Code generated by ./release-notes.sh at 'Mon Aug 31 15:08:14 CEST 2020'. DO NOT EDIT. package importexport diff --git a/internal/transfer/provider_pmapi_target.go b/internal/transfer/provider_pmapi_target.go index 40c7e92f..2220bcfc 100644 --- a/internal/transfer/provider_pmapi_target.go +++ b/internal/transfer/provider_pmapi_target.go @@ -206,7 +206,14 @@ func (p *PMAPIProvider) generateImportMsgReq(msg Message, globalMailbox *Mailbox }, nil } -func (p *PMAPIProvider) parseMessage(msg Message) (*pmapi.Message, []io.Reader, error) { +func (p *PMAPIProvider) parseMessage(msg Message) (m *pmapi.Message, r []io.Reader, err error) { + // Old message parser is panicking in some cases. + // Instead of crashing we try to convert to regular error. + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("%v", r) + } + }() message, _, _, attachmentReaders, err := pkgMessage.Parse(bytes.NewBuffer(msg.Body), "", "") return message, attachmentReaders, err } From bc078964369f374a915c05916e75e5c15383b9b7 Mon Sep 17 00:00:00 2001 From: Michal Horejsek Date: Mon, 31 Aug 2020 17:23:22 +0200 Subject: [PATCH 20/22] Sentry report after parser panic --- internal/importexport/importexport.go | 2 +- internal/transfer/provider_pmapi.go | 4 +++- internal/transfer/provider_pmapi_target.go | 11 ++++++++++- internal/transfer/provider_pmapi_test.go | 12 ++++++------ internal/transfer/transfer_test.go | 3 +++ 5 files changed, 23 insertions(+), 9 deletions(-) diff --git a/internal/importexport/importexport.go b/internal/importexport/importexport.go index 3cab2c92..26a3e1c2 100644 --- a/internal/importexport/importexport.go +++ b/internal/importexport/importexport.go @@ -167,5 +167,5 @@ func (ie *ImportExport) getPMAPIProvider(address string) (*transfer.PMAPIProvide log.WithError(err).Info("Address does not exist, using all addresses") } - return transfer.NewPMAPIProvider(ie.clientManager, user.ID(), addressID) + return transfer.NewPMAPIProvider(ie.config.GetAPIConfig(), ie.clientManager, user.ID(), addressID) } diff --git a/internal/transfer/provider_pmapi.go b/internal/transfer/provider_pmapi.go index 3baa5993..33210b9b 100644 --- a/internal/transfer/provider_pmapi.go +++ b/internal/transfer/provider_pmapi.go @@ -27,6 +27,7 @@ import ( // PMAPIProvider implements import and export to/from ProtonMail server. type PMAPIProvider struct { + clientConfig *pmapi.ClientConfig clientManager ClientManager userID string addressID string @@ -37,8 +38,9 @@ type PMAPIProvider struct { } // NewPMAPIProvider returns new PMAPIProvider. -func NewPMAPIProvider(clientManager ClientManager, userID, addressID string) (*PMAPIProvider, error) { +func NewPMAPIProvider(config *pmapi.ClientConfig, clientManager ClientManager, userID, addressID string) (*PMAPIProvider, error) { provider := &PMAPIProvider{ + clientConfig: config, clientManager: clientManager, userID: userID, addressID: addressID, diff --git a/internal/transfer/provider_pmapi_target.go b/internal/transfer/provider_pmapi_target.go index 2220bcfc..9fd3d3af 100644 --- a/internal/transfer/provider_pmapi_target.go +++ b/internal/transfer/provider_pmapi_target.go @@ -25,6 +25,7 @@ import ( pkgMessage "github.com/ProtonMail/proton-bridge/pkg/message" "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/ProtonMail/proton-bridge/pkg/sentry" "github.com/pkg/errors" ) @@ -211,7 +212,15 @@ func (p *PMAPIProvider) parseMessage(msg Message) (m *pmapi.Message, r []io.Read // Instead of crashing we try to convert to regular error. defer func() { if r := recover(); r != nil { - err = fmt.Errorf("%v", r) + err = fmt.Errorf("panic while parse: %v", r) + if sentryErr := sentry.ReportSentryCrash( + p.clientConfig.ClientID, + p.clientConfig.AppVersion, + p.clientConfig.UserAgent, + err, + ); sentryErr != nil { + log.Error("Sentry crash report failed: ", sentryErr) + } } }() message, _, _, attachmentReaders, err := pkgMessage.Parse(bytes.NewBuffer(msg.Body), "", "") diff --git a/internal/transfer/provider_pmapi_test.go b/internal/transfer/provider_pmapi_test.go index 76ce8327..fd32b887 100644 --- a/internal/transfer/provider_pmapi_test.go +++ b/internal/transfer/provider_pmapi_test.go @@ -33,7 +33,7 @@ func TestPMAPIProviderMailboxes(t *testing.T) { defer m.ctrl.Finish() setupPMAPIClientExpectationForExport(&m) - provider, err := NewPMAPIProvider(m.clientManager, "user", "addressID") + provider, err := NewPMAPIProvider(m.pmapiConfig, m.clientManager, "user", "addressID") r.NoError(t, err) tests := []struct { @@ -78,7 +78,7 @@ func TestPMAPIProviderTransferTo(t *testing.T) { defer m.ctrl.Finish() setupPMAPIClientExpectationForExport(&m) - provider, err := NewPMAPIProvider(m.clientManager, "user", "addressID") + provider, err := NewPMAPIProvider(m.pmapiConfig, m.clientManager, "user", "addressID") r.NoError(t, err) rules, rulesClose := newTestRules(t) @@ -96,7 +96,7 @@ func TestPMAPIProviderTransferFrom(t *testing.T) { defer m.ctrl.Finish() setupPMAPIClientExpectationForImport(&m) - provider, err := NewPMAPIProvider(m.clientManager, "user", "addressID") + provider, err := NewPMAPIProvider(m.pmapiConfig, m.clientManager, "user", "addressID") r.NoError(t, err) rules, rulesClose := newTestRules(t) @@ -114,7 +114,7 @@ func TestPMAPIProviderTransferFromDraft(t *testing.T) { defer m.ctrl.Finish() setupPMAPIClientExpectationForImportDraft(&m) - provider, err := NewPMAPIProvider(m.clientManager, "user", "addressID") + provider, err := NewPMAPIProvider(m.pmapiConfig, m.clientManager, "user", "addressID") r.NoError(t, err) rules, rulesClose := newTestRules(t) @@ -133,9 +133,9 @@ func TestPMAPIProviderTransferFromTo(t *testing.T) { setupPMAPIClientExpectationForExport(&m) setupPMAPIClientExpectationForImport(&m) - source, err := NewPMAPIProvider(m.clientManager, "user", "addressID") + source, err := NewPMAPIProvider(m.pmapiConfig, m.clientManager, "user", "addressID") r.NoError(t, err) - target, err := NewPMAPIProvider(m.clientManager, "user", "addressID") + target, err := NewPMAPIProvider(m.pmapiConfig, m.clientManager, "user", "addressID") r.NoError(t, err) rules, rulesClose := newTestRules(t) diff --git a/internal/transfer/transfer_test.go b/internal/transfer/transfer_test.go index 2d0128f9..653407f9 100644 --- a/internal/transfer/transfer_test.go +++ b/internal/transfer/transfer_test.go @@ -23,6 +23,7 @@ import ( "github.com/ProtonMail/gopenpgp/v2/crypto" transfermocks "github.com/ProtonMail/proton-bridge/internal/transfer/mocks" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" pmapimocks "github.com/ProtonMail/proton-bridge/pkg/pmapi/mocks" gomock "github.com/golang/mock/gomock" ) @@ -34,6 +35,7 @@ type mocks struct { panicHandler *transfermocks.MockPanicHandler clientManager *transfermocks.MockClientManager pmapiClient *pmapimocks.MockClient + pmapiConfig *pmapi.ClientConfig keyring *crypto.KeyRing } @@ -48,6 +50,7 @@ func initMocks(t *testing.T) mocks { panicHandler: transfermocks.NewMockPanicHandler(mockCtrl), clientManager: transfermocks.NewMockClientManager(mockCtrl), pmapiClient: pmapimocks.NewMockClient(mockCtrl), + pmapiConfig: &pmapi.ClientConfig{}, keyring: newTestKeyring(), } From bf6963859f3c9d2e866e2e92f242ee3768d4260b Mon Sep 17 00:00:00 2001 From: Jakub Date: Thu, 3 Sep 2020 12:07:39 +0200 Subject: [PATCH 21/22] rename IE app GODT-690 --- cmd/Import-Export/main.go | 2 +- internal/frontend/qml/tst_GuiIE.qml | 2 +- internal/updates/updates.go | 107 +++++++++++++++------------- 3 files changed, 60 insertions(+), 51 deletions(-) diff --git a/cmd/Import-Export/main.go b/cmd/Import-Export/main.go index 1a45b6f4..cc0ae6ff 100644 --- a/cmd/Import-Export/main.go +++ b/cmd/Import-Export/main.go @@ -37,7 +37,7 @@ import ( const ( appName = "importExport" - appNameDash = "import-export" + appNameDash = "import-export-app" ) var ( diff --git a/internal/frontend/qml/tst_GuiIE.qml b/internal/frontend/qml/tst_GuiIE.qml index 48dcc460..8d9ed46c 100644 --- a/internal/frontend/qml/tst_GuiIE.qml +++ b/internal/frontend/qml/tst_GuiIE.qml @@ -830,7 +830,7 @@ Window { property string bugNotSent property string bugReportSent - property string programTitle : "ProtonMail Import-Export App" + property string programTitle : "ProtonMail Import-Export app" property string newversion : "q0.1.0" property string landingPage : "https://landing.page" property string changelog : "• Lorem ipsum dolor sit amet\n• consetetur sadipscing elitr,\n• sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat,\n• sed diam voluptua.\n• At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet." diff --git a/internal/updates/updates.go b/internal/updates/updates.go index e6dcfc5b..b864dc57 100644 --- a/internal/updates/updates.go +++ b/internal/updates/updates.go @@ -49,65 +49,62 @@ var ( var ( log = logrus.WithField("pkg", "bridgeUtils/updates") //nolint[gochecknoglobals] - installFileSuffix = map[string]string{ //nolint[gochecknoglobals] - "darwin": ".dmg", - "windows": ".exe", - "linux": ".sh", - } - ErrDownloadFailed = errors.New("error happened during download") //nolint[gochecknoglobals] ErrUpdateVerifyFailed = errors.New("cannot verify signature") //nolint[gochecknoglobals] ) type Updates struct { - version string - revision string - buildTime string - releaseNotes string - releaseFixedBugs string - updateTempDir string - landingPagePath string // Based on Host/; default landing page for download. - installerFileBaseName string // File for initial install or manual reinstall. per goos [exe, dmg, sh]. - versionFileBaseName string // Text file containing information about current file. per goos [_linux,_darwin,_windows].json (have .sig file). - updateFileBaseName string // File for automatic update. per goos [_linux,_darwin,_windows].tgz (have .sig file). - linuxFileBaseName string // Prefix of linux package names. - macAppBundleName string // Name of Mac app file in the bundle for update procedure. - cachedNewerVersion *VersionInfo // To have info about latest version even when the internet connection drops. + version string + revision string + buildTime string + releaseNotes string + releaseFixedBugs string + updateTempDir string + landingPagePath string // Based on Host/; default landing page for download. + winInstallerFile string // File for initial install or manual reinstall for windows + macInstallerFile string // File for initial install or manual reinstall for mac + versionFileBaseName string // Text file containing information about current file. per goos [_linux,_darwin,_windows].json (have .sig file). + updateFileBaseName string // File for automatic update. per goos [_linux,_darwin,_windows].tgz (have .sig file). + linuxFileBaseName string // Prefix of linux package names. + macAppBundleName string // Name of Mac app file in the bundle for update procedure. + cachedNewerVersion *VersionInfo // To have info about latest version even when the internet connection drops. } // NewBridge inits Updates struct for bridge. func NewBridge(updateTempDir string) *Updates { return &Updates{ - version: constants.Version, - revision: constants.Revision, - buildTime: constants.BuildTime, - releaseNotes: bridge.ReleaseNotes, - releaseFixedBugs: bridge.ReleaseFixedBugs, - updateTempDir: updateTempDir, - landingPagePath: "bridge/download", - installerFileBaseName: "Bridge-Installer", - versionFileBaseName: "current_version", - updateFileBaseName: "bridge_upgrade", - linuxFileBaseName: "protonmail-bridge", - macAppBundleName: "ProtonMail Bridge.app", + version: constants.Version, + revision: constants.Revision, + buildTime: constants.BuildTime, + releaseNotes: bridge.ReleaseNotes, + releaseFixedBugs: bridge.ReleaseFixedBugs, + updateTempDir: updateTempDir, + landingPagePath: "bridge/download", + macInstallerFile: "Bridge-Installer.dmg", + winInstallerFile: "Bridge-Installer.exe", + versionFileBaseName: "current_version", + updateFileBaseName: "bridge_upgrade", + linuxFileBaseName: "protonmail-bridge", + macAppBundleName: "ProtonMail Bridge.app", } } // NewImportExport inits Updates struct for import-export. func NewImportExport(updateTempDir string) *Updates { return &Updates{ - version: constants.Version, - revision: constants.Revision, - buildTime: constants.BuildTime, - releaseNotes: importexport.ReleaseNotes, - releaseFixedBugs: importexport.ReleaseFixedBugs, - updateTempDir: updateTempDir, - landingPagePath: "import-export", - installerFileBaseName: "Import-Export-Installer", - versionFileBaseName: "current_version_ie", - updateFileBaseName: "ie_upgrade", - linuxFileBaseName: "protonmail-import-export", - macAppBundleName: "ProtonMail Import-Export.app", + version: constants.Version, + revision: constants.Revision, + buildTime: constants.BuildTime, + releaseNotes: importexport.ReleaseNotes, + releaseFixedBugs: importexport.ReleaseFixedBugs, + updateTempDir: updateTempDir, + landingPagePath: "import-export", + winInstallerFile: "ie/Import-Export-app-installer.exe", + macInstallerFile: "ie/Import-Export-app.dmg", + versionFileBaseName: "current_version_ie", + updateFileBaseName: "ie/ie_upgrade", + linuxFileBaseName: "ie/protonmail-import-export-app", + macAppBundleName: "Import-Export app.app", } } @@ -187,11 +184,15 @@ func (u *Updates) getLocalVersion(goos string) VersionInfo { if goos == "linux" { pkgName := u.linuxFileBaseName pkgRel := "1" - pkgBase := strings.Join([]string{Host, DownloadPath, pkgName}, "/") + pkgBaseFile := strings.Join([]string{Host, DownloadPath, pkgName}, "/") - versionInfo.DebFile = pkgBase + "_" + u.version + "-" + pkgRel + "_amd64.deb" - versionInfo.RpmFile = pkgBase + "-" + u.version + "-" + pkgRel + ".x86_64.rpm" - versionInfo.PkgFile = strings.Join([]string{Host, DownloadPath, "PKGBUILD"}, "/") + pkgBasePath := DownloadPath + "/" + pkgName // add at least one dir + pkgBasePath = filepath.Dir(pkgBasePath) // keep only last dir + pkgBasePath = Host + "/" + pkgBasePath // add host in the end to not strip off double slash in URL + + versionInfo.DebFile = pkgBaseFile + "_" + u.version + "-" + pkgRel + "_amd64.deb" + versionInfo.RpmFile = pkgBaseFile + "-" + u.version + "-" + pkgRel + ".x86_64.rpm" + versionInfo.PkgFile = strings.Join([]string{pkgBasePath, "PKGBUILD"}, "/") } return versionInfo @@ -240,7 +241,14 @@ func (u *Updates) versionFileURL(goos string) string { } func (u *Updates) installerFileURL(goos string) string { - return strings.Join([]string{Host, DownloadPath, u.installerFileBaseName + installFileSuffix[goos]}, "/") + installerFile := "none" + switch goos { + case "darwin": + installerFile = u.macInstallerFile + case "windows": + installerFile = u.winInstallerFile + } + return strings.Join([]string{Host, DownloadPath, installerFile}, "/") } func (u *Updates) updateFileURL(goos string) string { @@ -299,7 +307,8 @@ func (u *Updates) StartUpgrade(currentStatus chan<- Progress) { // nolint[funlen status.UpdateDescription(InfoUpgrading) switch runtime.GOOS { case "windows": //nolint[goconst] - cmd := exec.Command("./" + u.installerFileBaseName) // nolint[gosec] + installerFile := strings.Split(u.winInstallerFile, "/")[1] + cmd := exec.Command("./" + installerFile) // nolint[gosec] cmd.Dir = u.updateTempDir status.Err = cmd.Start() case "darwin": From bfdfc81d65dacde57538def61af70195ca397b02 Mon Sep 17 00:00:00 2001 From: Jakub Date: Fri, 4 Sep 2020 13:59:10 +0200 Subject: [PATCH 22/22] release notes --- internal/bridge/credits.go | 4 ++-- internal/bridge/release_notes.go | 2 +- internal/importexport/credits.go | 4 ++-- internal/importexport/release_notes.go | 9 +++++---- internal/updates/updates.go | 13 ++++++++----- release-notes/notes-importexport.txt | 7 ++++--- 6 files changed, 22 insertions(+), 17 deletions(-) diff --git a/internal/bridge/credits.go b/internal/bridge/credits.go index 9df2931e..8394fe86 100644 --- a/internal/bridge/credits.go +++ b/internal/bridge/credits.go @@ -15,8 +15,8 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -// Code generated by ./credits.sh at Mon Aug 31 15:08:14 CEST 2020. DO NOT EDIT. +// Code generated by ./credits.sh at Fri 04 Sep 2020 01:57:36 PM CEST. DO NOT EDIT. package bridge -const Credits = "github.com/0xAX/notificator;github.com/ProtonMail/bcrypt;github.com/ProtonMail/crypto;github.com/ProtonMail/docker-credential-helpers;github.com/ProtonMail/go-appdir;github.com/ProtonMail/go-apple-mobileconfig;github.com/ProtonMail/go-autostart;github.com/ProtonMail/go-imap-id;github.com/ProtonMail/go-smtp;github.com/ProtonMail/go-vcard;github.com/ProtonMail/gopenpgp/v2;github.com/abiosoft/ishell;github.com/abiosoft/readline;github.com/allan-simon/go-singleinstance;github.com/andybalholm/cascadia;github.com/certifi/gocertifi;github.com/chzyer/logex;github.com/chzyer/test;github.com/cucumber/godog;github.com/docker/docker-credential-helpers;github.com/emersion/go-imap;github.com/emersion/go-imap-appendlimit;github.com/emersion/go-imap-idle;github.com/emersion/go-imap-move;github.com/emersion/go-imap-quota;github.com/emersion/go-imap-specialuse;github.com/emersion/go-imap-unselect;github.com/emersion/go-mbox;github.com/emersion/go-message;github.com/emersion/go-sasl;github.com/emersion/go-smtp;github.com/emersion/go-textwrapper;github.com/emersion/go-vcard;github.com/fatih/color;github.com/flynn-archive/go-shlex;github.com/getsentry/raven-go;github.com/go-delve/delve;github.com/go-resty/resty/v2;github.com/golang/mock;github.com/google/go-cmp;github.com/google/uuid;github.com/gopherjs/gopherjs;github.com/hashicorp/go-multierror;github.com/jameshoulahan/go-imap;github.com/jameskeane/bcrypt;github.com/jaytaylor/html2text;github.com/jhillyerd/enmime;github.com/kardianos/osext;github.com/keybase/go-keychain;github.com/logrusorgru/aurora;github.com/mattn/go-runewidth;github.com/miekg/dns;github.com/myesui/uuid;github.com/nsf/jsondiff;github.com/olekukonko/tablewriter;github.com/pkg/errors;github.com/psampaz/go-mod-outdated;github.com/sirupsen/logrus;github.com/skratchdot/open-golang;github.com/stretchr/testify;github.com/therecipe/qt;github.com/twinj/uuid;github.com/urfave/cli;go.etcd.io/bbolt;golang.org/x/crypto;golang.org/x/net;golang.org/x/text;gopkg.in/stretchr/testify.v1;;Font Awesome 4.7.0;;Qt 5.13 by Qt group;" +const Credits = "github.com/0xAX/notificator;github.com/abiosoft/ishell;github.com/abiosoft/readline;github.com/allan-simon/go-singleinstance;github.com/andybalholm/cascadia;github.com/certifi/gocertifi;github.com/chzyer/logex;github.com/chzyer/test;github.com/cucumber/godog;github.com/docker/docker-credential-helpers;github.com/emersion/go-imap;github.com/emersion/go-imap-appendlimit;github.com/emersion/go-imap-idle;github.com/emersion/go-imap-move;github.com/emersion/go-imap-quota;github.com/emersion/go-imap-specialuse;github.com/emersion/go-imap-unselect;github.com/emersion/go-mbox;github.com/emersion/go-message;github.com/emersion/go-sasl;github.com/emersion/go-smtp;github.com/emersion/go-textwrapper;github.com/emersion/go-vcard;github.com/fatih/color;github.com/flynn-archive/go-shlex;github.com/getsentry/raven-go;github.com/go-delve/delve;github.com/golang/mock;github.com/google/go-cmp;github.com/google/uuid;github.com/gopherjs/gopherjs;github.com/go-resty/resty/v2;github.com/hashicorp/go-multierror;github.com/jameshoulahan/go-imap;github.com/jameskeane/bcrypt;github.com/jaytaylor/html2text;github.com/jhillyerd/enmime;github.com/kardianos/osext;github.com/keybase/go-keychain;github.com/logrusorgru/aurora;github.com/mattn/go-runewidth;github.com/miekg/dns;github.com/myesui/uuid;github.com/nsf/jsondiff;github.com/olekukonko/tablewriter;github.com/pkg/errors;github.com/ProtonMail/bcrypt;github.com/ProtonMail/crypto;github.com/ProtonMail/docker-credential-helpers;github.com/ProtonMail/go-appdir;github.com/ProtonMail/go-apple-mobileconfig;github.com/ProtonMail/go-autostart;github.com/ProtonMail/go-imap-id;github.com/ProtonMail/gopenpgp/v2;github.com/ProtonMail/go-smtp;github.com/ProtonMail/go-vcard;github.com/psampaz/go-mod-outdated;github.com/sirupsen/logrus;github.com/skratchdot/open-golang;github.com/stretchr/testify;github.com/therecipe/qt;github.com/twinj/uuid;github.com/urfave/cli;go.etcd.io/bbolt;golang.org/x/crypto;golang.org/x/net;golang.org/x/text;gopkg.in/stretchr/testify.v1;;Font Awesome 4.7.0;;Qt 5.13 by Qt group;" diff --git a/internal/bridge/release_notes.go b/internal/bridge/release_notes.go index 5b7673fd..eee5f3ba 100644 --- a/internal/bridge/release_notes.go +++ b/internal/bridge/release_notes.go @@ -15,7 +15,7 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -// Code generated by ./release-notes.sh at 'Mon Aug 31 15:08:14 CEST 2020'. DO NOT EDIT. +// Code generated by ./release-notes.sh at 'Fri 04 Sep 2020 01:57:36 PM CEST'. DO NOT EDIT. package bridge diff --git a/internal/importexport/credits.go b/internal/importexport/credits.go index 6907b3cb..d2d63ae6 100644 --- a/internal/importexport/credits.go +++ b/internal/importexport/credits.go @@ -15,8 +15,8 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -// Code generated by ./credits.sh at Mon Aug 31 15:08:14 CEST 2020. DO NOT EDIT. +// Code generated by ./credits.sh at Fri 04 Sep 2020 01:57:36 PM CEST. DO NOT EDIT. package importexport -const Credits = "github.com/0xAX/notificator;github.com/ProtonMail/bcrypt;github.com/ProtonMail/crypto;github.com/ProtonMail/docker-credential-helpers;github.com/ProtonMail/go-appdir;github.com/ProtonMail/go-apple-mobileconfig;github.com/ProtonMail/go-autostart;github.com/ProtonMail/go-imap-id;github.com/ProtonMail/go-smtp;github.com/ProtonMail/go-vcard;github.com/ProtonMail/gopenpgp/v2;github.com/abiosoft/ishell;github.com/abiosoft/readline;github.com/allan-simon/go-singleinstance;github.com/andybalholm/cascadia;github.com/certifi/gocertifi;github.com/chzyer/logex;github.com/chzyer/test;github.com/cucumber/godog;github.com/docker/docker-credential-helpers;github.com/emersion/go-imap;github.com/emersion/go-imap-appendlimit;github.com/emersion/go-imap-idle;github.com/emersion/go-imap-move;github.com/emersion/go-imap-quota;github.com/emersion/go-imap-specialuse;github.com/emersion/go-imap-unselect;github.com/emersion/go-mbox;github.com/emersion/go-message;github.com/emersion/go-sasl;github.com/emersion/go-smtp;github.com/emersion/go-textwrapper;github.com/emersion/go-vcard;github.com/fatih/color;github.com/flynn-archive/go-shlex;github.com/getsentry/raven-go;github.com/go-delve/delve;github.com/go-resty/resty/v2;github.com/golang/mock;github.com/google/go-cmp;github.com/google/uuid;github.com/gopherjs/gopherjs;github.com/hashicorp/go-multierror;github.com/jameshoulahan/go-imap;github.com/jameskeane/bcrypt;github.com/jaytaylor/html2text;github.com/jhillyerd/enmime;github.com/kardianos/osext;github.com/keybase/go-keychain;github.com/logrusorgru/aurora;github.com/mattn/go-runewidth;github.com/miekg/dns;github.com/myesui/uuid;github.com/nsf/jsondiff;github.com/olekukonko/tablewriter;github.com/pkg/errors;github.com/psampaz/go-mod-outdated;github.com/sirupsen/logrus;github.com/skratchdot/open-golang;github.com/stretchr/testify;github.com/therecipe/qt;github.com/twinj/uuid;github.com/urfave/cli;go.etcd.io/bbolt;golang.org/x/crypto;golang.org/x/net;golang.org/x/text;gopkg.in/stretchr/testify.v1;;Font Awesome 4.7.0;;Qt 5.13 by Qt group;" +const Credits = "github.com/0xAX/notificator;github.com/abiosoft/ishell;github.com/abiosoft/readline;github.com/allan-simon/go-singleinstance;github.com/andybalholm/cascadia;github.com/certifi/gocertifi;github.com/chzyer/logex;github.com/chzyer/test;github.com/cucumber/godog;github.com/docker/docker-credential-helpers;github.com/emersion/go-imap;github.com/emersion/go-imap-appendlimit;github.com/emersion/go-imap-idle;github.com/emersion/go-imap-move;github.com/emersion/go-imap-quota;github.com/emersion/go-imap-specialuse;github.com/emersion/go-imap-unselect;github.com/emersion/go-mbox;github.com/emersion/go-message;github.com/emersion/go-sasl;github.com/emersion/go-smtp;github.com/emersion/go-textwrapper;github.com/emersion/go-vcard;github.com/fatih/color;github.com/flynn-archive/go-shlex;github.com/getsentry/raven-go;github.com/go-delve/delve;github.com/golang/mock;github.com/google/go-cmp;github.com/google/uuid;github.com/gopherjs/gopherjs;github.com/go-resty/resty/v2;github.com/hashicorp/go-multierror;github.com/jameshoulahan/go-imap;github.com/jameskeane/bcrypt;github.com/jaytaylor/html2text;github.com/jhillyerd/enmime;github.com/kardianos/osext;github.com/keybase/go-keychain;github.com/logrusorgru/aurora;github.com/mattn/go-runewidth;github.com/miekg/dns;github.com/myesui/uuid;github.com/nsf/jsondiff;github.com/olekukonko/tablewriter;github.com/pkg/errors;github.com/ProtonMail/bcrypt;github.com/ProtonMail/crypto;github.com/ProtonMail/docker-credential-helpers;github.com/ProtonMail/go-appdir;github.com/ProtonMail/go-apple-mobileconfig;github.com/ProtonMail/go-autostart;github.com/ProtonMail/go-imap-id;github.com/ProtonMail/gopenpgp/v2;github.com/ProtonMail/go-smtp;github.com/ProtonMail/go-vcard;github.com/psampaz/go-mod-outdated;github.com/sirupsen/logrus;github.com/skratchdot/open-golang;github.com/stretchr/testify;github.com/therecipe/qt;github.com/twinj/uuid;github.com/urfave/cli;go.etcd.io/bbolt;golang.org/x/crypto;golang.org/x/net;golang.org/x/text;gopkg.in/stretchr/testify.v1;;Font Awesome 4.7.0;;Qt 5.13 by Qt group;" diff --git a/internal/importexport/release_notes.go b/internal/importexport/release_notes.go index 14c883f2..cac52dea 100644 --- a/internal/importexport/release_notes.go +++ b/internal/importexport/release_notes.go @@ -15,14 +15,15 @@ // You should have received a copy of the GNU General Public License // along with ProtonMail Bridge. If not, see . -// Code generated by ./release-notes.sh at 'Mon Aug 31 15:08:14 CEST 2020'. DO NOT EDIT. +// Code generated by ./release-notes.sh at 'Fri 04 Sep 2020 01:57:36 PM CEST'. DO NOT EDIT. package importexport -const ReleaseNotes = `• Note: you need to log in again -• Complete code refactor in preparation of live release and open source of the Import-Export app +const ReleaseNotes = `***Note: If you were using the Import-Export app before, you need to uninstall the older version and log in again to the new version*** + +• Complete code refactor in preparation of stable and open source release of the Import-Export app • Increased number of supported mail providers by changing the way the folder structures are handled (NIL hierarchy delimiter) -• Improved handling for unstable internet and pause & resume behavior +• Improved handling for unstable internet and pause and resume behavior ` const ReleaseFixedBugs = `• Fixed rare cases where the application freezes when starting/stopping imports diff --git a/internal/updates/updates.go b/internal/updates/updates.go index b864dc57..538c9d84 100644 --- a/internal/updates/updates.go +++ b/internal/updates/updates.go @@ -63,6 +63,7 @@ type Updates struct { landingPagePath string // Based on Host/; default landing page for download. winInstallerFile string // File for initial install or manual reinstall for windows macInstallerFile string // File for initial install or manual reinstall for mac + linInstallerFile string // File for initial install or manual reinstall for linux versionFileBaseName string // Text file containing information about current file. per goos [_linux,_darwin,_windows].json (have .sig file). updateFileBaseName string // File for automatic update. per goos [_linux,_darwin,_windows].tgz (have .sig file). linuxFileBaseName string // Prefix of linux package names. @@ -80,8 +81,9 @@ func NewBridge(updateTempDir string) *Updates { releaseFixedBugs: bridge.ReleaseFixedBugs, updateTempDir: updateTempDir, landingPagePath: "bridge/download", - macInstallerFile: "Bridge-Installer.dmg", winInstallerFile: "Bridge-Installer.exe", + macInstallerFile: "Bridge-Installer.dmg", + linInstallerFile: "Bridge-Installer.sh", versionFileBaseName: "current_version", updateFileBaseName: "bridge_upgrade", linuxFileBaseName: "protonmail-bridge", @@ -101,6 +103,7 @@ func NewImportExport(updateTempDir string) *Updates { landingPagePath: "import-export", winInstallerFile: "ie/Import-Export-app-installer.exe", macInstallerFile: "ie/Import-Export-app.dmg", + linInstallerFile: "ie/Import-Export-app-installer.sh", versionFileBaseName: "current_version_ie", updateFileBaseName: "ie/ie_upgrade", linuxFileBaseName: "ie/protonmail-import-export-app", @@ -241,11 +244,11 @@ func (u *Updates) versionFileURL(goos string) string { } func (u *Updates) installerFileURL(goos string) string { - installerFile := "none" + installerFile := u.linInstallerFile switch goos { - case "darwin": + case "darwin": //nolint[goconst] installerFile = u.macInstallerFile - case "windows": + case "windows": //nolint[goconst] installerFile = u.winInstallerFile } return strings.Join([]string{Host, DownloadPath, installerFile}, "/") @@ -311,7 +314,7 @@ func (u *Updates) StartUpgrade(currentStatus chan<- Progress) { // nolint[funlen cmd := exec.Command("./" + installerFile) // nolint[gosec] cmd.Dir = u.updateTempDir status.Err = cmd.Start() - case "darwin": + case "darwin": //nolint[goconst] // current path is better then appDir = filepath.Join("/Applications") var exePath string exePath, status.Err = osext.Executable() diff --git a/release-notes/notes-importexport.txt b/release-notes/notes-importexport.txt index d4b2a758..1eda3f76 100644 --- a/release-notes/notes-importexport.txt +++ b/release-notes/notes-importexport.txt @@ -1,4 +1,5 @@ -• Note: you need to log in again -• Complete code refactor in preparation of live release and open source of the Import-Export app +***Note: If you were using the Import-Export app before, you need to uninstall the older version and log in again to the new version*** + +• Complete code refactor in preparation of stable and open source release of the Import-Export app • Increased number of supported mail providers by changing the way the folder structures are handled (NIL hierarchy delimiter) -• Improved handling for unstable internet and pause & resume behavior +• Improved handling for unstable internet and pause and resume behavior