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}}
-
- {{- 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
+
+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/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}}
+
+ {{- 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