Import/Export backend

This commit is contained in:
Michal Horejsek
2020-06-17 15:29:41 +02:00
parent 49316a935c
commit 1c10cc5065
107 changed files with 2869 additions and 743 deletions

View File

@ -77,7 +77,6 @@ dependency-updates:
build-linux:
stage: build
# Test build every time (= we want to know build is possible).
only:
- branches
script:
@ -88,6 +87,18 @@ build-linux:
- bridge_*.tgz
expire_in: 2 week
build-ie-linux:
stage: build
only:
- branches
script:
- make build-ie
artifacts:
name: "bridge-linux-$CI_COMMIT_SHORT_SHA"
paths:
- bridge_*.tgz
expire_in: 2 week
build-darwin:
stage: build
only:
@ -114,6 +125,31 @@ build-darwin:
- bridge_*.tgz
expire_in: 2 week
build-ie-darwin:
stage: build
only:
- branches
before_script:
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null
- export PATH=/usr/local/bin:$PATH
- export PATH=/usr/local/opt/git/bin:$PATH
- export PATH=/usr/local/opt/make/libexec/gnubin:$PATH
- export PATH=/usr/local/opt/go@1.13/bin:$PATH
- export PATH=/usr/local/opt/gnu-sed/libexec/gnubin:$PATH
- export GOPATH=~/go
- export PATH=$GOPATH/bin:$PATH
cache: {}
tags:
- macOS-bridge
script:
- make build-ie
artifacts:
name: "bridge-darwin-$CI_COMMIT_SHORT_SHA"
paths:
- bridge_*.tgz
expire_in: 2 week
build-windows:
stage: build
services:
@ -136,6 +172,30 @@ build-windows:
- bridge_*.tgz
expire_in: 2 week
build-ie-windows:
stage: build
services:
- docker:dind
only:
- branches
variables:
DOCKER_HOST: tcp://docker:2375
script:
# We need to install docker because qtdeploy builds for windows inside a docker container.
# Docker will connect to the dockerd daemon provided by the runner service docker:dind at tcp://docker:2375.
- curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh
- apt-get update && apt-get -y install binutils-mingw-w64 tar gzip
- ln -s /usr/bin/x86_64-w64-mingw32-windres /usr/bin/windres
- go mod download
- TARGET_OS=windows make build-ie
artifacts:
name: "bridge-windows-$CI_COMMIT_SHORT_SHA"
paths:
- bridge_*.tgz
expire_in: 2 week
# Stage: MIRROR
mirror-repo:
stage: mirror
only:

View File

@ -9,7 +9,12 @@ TARGET_OS?=${GOOS}
## Build
.PHONY: build build-ie build-nogui build-ie-nogui check-has-go
APP_VERSION?=$(shell git describe --abbrev=0 --tags)-git
BRIDGE_APP_VERSION?=1.4.0-git
IE_APP_VERSION?=1.0.0-git
APP_VERSION=${BRIDGE_APP_VERSION}
ifeq "${TARGET_CMD}" "Import-Export"
APP_VERSION=${IE_APP_VERSION}
endif
REVISION:=$(shell git rev-parse --short=10 HEAD)
BUILD_TIME:=$(shell date +%FT%T%z)

View File

@ -47,6 +47,7 @@ or
### Integration testing
- `TEST_ENV`: set which env to use (fake or live)
- `TEST_APP`: set which app to test (bridge or ie)
- `TEST_ACCOUNTS`: set JSON file with configured accounts
- `TAGS`: set build tags for tests
- `FEATURES`: set feature dir, file or scenario to test

View File

@ -35,18 +35,13 @@ package main
*/
import (
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"runtime"
"runtime/pprof"
"strconv"
"strings"
"github.com/ProtonMail/proton-bridge/internal/api"
"github.com/ProtonMail/proton-bridge/internal/bridge"
"github.com/ProtonMail/proton-bridge/internal/cmd"
"github.com/ProtonMail/proton-bridge/internal/cookies"
"github.com/ProtonMail/proton-bridge/internal/events"
"github.com/ProtonMail/proton-bridge/internal/frontend"
@ -54,104 +49,42 @@ import (
"github.com/ProtonMail/proton-bridge/internal/preferences"
"github.com/ProtonMail/proton-bridge/internal/smtp"
"github.com/ProtonMail/proton-bridge/internal/users/credentials"
"github.com/ProtonMail/proton-bridge/pkg/args"
"github.com/ProtonMail/proton-bridge/pkg/config"
"github.com/ProtonMail/proton-bridge/pkg/constants"
"github.com/ProtonMail/proton-bridge/pkg/listener"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
"github.com/ProtonMail/proton-bridge/pkg/updates"
"github.com/allan-simon/go-singleinstance"
"github.com/getsentry/raven-go"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
)
// cacheVersion is used for cache files such as lock, events, preferences, user_info, db files.
// Different number will drop old files and create new ones.
const cacheVersion = "c11"
const (
// cacheVersion is used for cache files such as lock, events, preferences, user_info, db files.
// Different number will drop old files and create new ones.
cacheVersion = "c11"
appName = "bridge"
)
var (
log = logrus.WithField("pkg", "main") //nolint[gochecknoglobals]
// How many crashes in a row.
numberOfCrashes = 0 //nolint[gochecknoglobals]
// After how many crashes bridge gives up starting.
maxAllowedCrashes = 10 //nolint[gochecknoglobals]
)
func main() {
if err := raven.SetDSN(constants.DSNSentry); err != nil {
log.WithError(err).Errorln("Can not setup sentry DSN")
}
raven.SetRelease(constants.Revision)
args.FilterProcessSerialNumberFromArgs()
filterRestartNumberFromArgs()
app := cli.NewApp()
app.Name = "Protonmail Bridge"
app.Version = constants.BuildVersion
app.Flags = []cli.Flag{
cli.StringFlag{
Name: "log-level, l",
Usage: "Set the log level (one of panic, fatal, error, warn, info, debug, debug-client, debug-server)"},
cli.BoolFlag{
Name: "no-window",
Usage: "Don't show window after start"},
cli.BoolFlag{
Name: "cli, c",
Usage: "Use command line interface"},
cli.BoolFlag{
Name: "noninteractive",
Usage: "Start Bridge entirely noninteractively"},
cli.StringFlag{
Name: "version-json, g",
Usage: "Generate json version file"},
cli.BoolFlag{
Name: "mem-prof, m",
Usage: "Generate memory profile"},
cli.BoolFlag{
Name: "cpu-prof, p",
Usage: "Generate CPU profile"},
}
app.Usage = "ProtonMail IMAP and SMTP Bridge"
app.Action = run
// Always log the basic info about current bridge.
logrus.SetLevel(logrus.InfoLevel)
log.WithField("version", constants.Version).
WithField("revision", constants.Revision).
WithField("runtime", runtime.GOOS).
WithField("build", constants.BuildTime).
WithField("args", os.Args).
WithField("appLong", app.Name).
WithField("appShort", constants.AppShortName).
Info("Run app")
if err := app.Run(os.Args); err != nil {
log.Error("Program exited with error: ", err)
}
}
type panicHandler struct {
cfg *config.Config
err *error // Pointer to error of cli action.
}
func (ph *panicHandler) HandlePanic() {
r := recover()
if r == nil {
return
}
config.HandlePanic(ph.cfg, fmt.Sprintf("Recover: %v", r))
frontend.HandlePanic("ProtonMail Bridge")
*ph.err = cli.NewExitError("Panic and restart", 255)
numberOfCrashes++
log.Error("Restarting after panic")
restartApp()
os.Exit(255)
cmd.Main(
"ProtonMail Bridge",
"ProtonMail IMAP and SMTP Bridge",
[]cli.Flag{
cli.BoolFlag{
Name: "no-window",
Usage: "Don't show window after start"},
cli.BoolFlag{
Name: "noninteractive",
Usage: "Start Bridge entirely noninteractively"},
},
run,
)
}
// run initializes and starts everything in a precise order.
@ -159,13 +92,17 @@ func (ph *panicHandler) HandlePanic() {
// IMPORTANT: ***Read the comments before CHANGING the order ***
func run(context *cli.Context) (contextError error) { // nolint[funlen]
// We need to have config instance to setup a logs, panic handler, etc ...
cfg := config.New(constants.AppShortName, constants.Version, constants.Revision, cacheVersion)
cfg := config.New(appName, constants.Version, constants.Revision, cacheVersion)
// We want to know about any problem. Our PanicHandler calls sentry which is
// not dependent on anything else. If that fails, it tries to create crash
// report which will not be possible if no folder can be created. That's the
// only problem we will not be notified about in any way.
panicHandler := &panicHandler{cfg, &contextError}
panicHandler := &cmd.PanicHandler{
AppName: "ProtonMail Bridge",
Config: cfg,
Err: &contextError,
}
defer panicHandler.HandlePanic()
// First we need config and create necessary folder; it's dependency for everything.
@ -177,13 +114,6 @@ func run(context *cli.Context) (contextError error) { // nolint[funlen]
logLevel := context.GlobalString("log-level")
debugClient, debugServer := config.SetupLog(cfg, logLevel)
// Should be called after logs are configured but before preferences are created.
migratePreferencesFromC10(cfg)
if err := cfg.ClearOldData(); err != nil {
log.Error("Cannot clear old data: ", err)
}
// Doesn't make sense to continue when Bridge was invoked with wrong arguments.
// We should tell that to the user before we do anything else.
if context.Args().First() != "" {
@ -193,21 +123,16 @@ func run(context *cli.Context) (contextError error) { // nolint[funlen]
// It's safe to get version JSON file even when other instance is running.
// (thus we put it before check of presence of other Bridge instance).
updates := updates.New(
constants.AppShortName,
constants.Version,
constants.Revision,
constants.BuildTime,
bridge.ReleaseNotes,
bridge.ReleaseFixedBugs,
cfg.GetUpdateDir(),
)
updates := updates.NewBridge(cfg.GetUpdateDir())
if dir := context.GlobalString("version-json"); dir != "" {
generateVersionFiles(updates, dir)
cmd.GenerateVersionFiles(updates, dir)
return nil
}
// Should be called after logs are configured but before preferences are created.
migratePreferencesFromC10(cfg)
// ClearOldData before starting new bridge to do a proper setup.
//
// IMPORTANT: If you the change position of this you will need to wait
@ -234,7 +159,7 @@ func run(context *cli.Context) (contextError error) { // nolint[funlen]
if err != nil {
log.Warn("Bridge is already running")
if err := api.CheckOtherInstanceAndFocus(pref.GetInt(preferences.APIPortKey), tls); err != nil {
numberOfCrashes = maxAllowedCrashes
cmd.DisableRestart()
log.Error("Second instance: ", err)
}
return cli.NewExitError("Bridge is already running.", 3)
@ -254,7 +179,7 @@ func run(context *cli.Context) (contextError error) { // nolint[funlen]
}
if doMemoryProfile := context.GlobalBool("mem-prof"); doMemoryProfile {
defer makeMemoryProfile()
defer cmd.MakeMemoryProfile()
}
// Now we initialize all Bridge parts.
@ -262,7 +187,7 @@ func run(context *cli.Context) (contextError error) { // nolint[funlen]
eventListener := listener.New()
events.SetupEvents(eventListener)
credentialsStore, credentialsError := credentials.NewStore("bridge")
credentialsStore, credentialsError := credentials.NewStore(appName)
if credentialsError != nil {
log.Error("Could not get credentials store: ", credentialsError)
}
@ -338,7 +263,7 @@ func run(context *cli.Context) (contextError error) { // nolint[funlen]
}
if frontend.IsAppRestarting() {
restartApp()
cmd.RestartApp()
}
return nil
@ -348,7 +273,7 @@ func run(context *cli.Context) (contextError error) { // nolint[funlen]
// It will happen only when c10/prefs.json exists and c11/prefs.json not.
// No configuration changed between c10 and c11 versions.
func migratePreferencesFromC10(cfg *config.Config) {
pref10Path := config.New(constants.AppShortName, constants.Version, constants.Revision, "c10").GetPreferencesPath()
pref10Path := config.New(appName, constants.Version, constants.Revision, "c10").GetPreferencesPath()
if _, err := os.Stat(pref10Path); os.IsNotExist(err) {
log.WithField("path", pref10Path).Trace("Old preferences does not exist, migration skipped")
return
@ -374,68 +299,3 @@ func migratePreferencesFromC10(cfg *config.Config) {
log.Info("Preferences migrated")
}
// generateVersionFiles writes a JSON file with details about current build.
// Those files are used for upgrading the app.
func generateVersionFiles(updates *updates.Updates, dir string) {
log.Info("Generating version files")
for _, goos := range []string{"windows", "darwin", "linux"} {
log.Debug("Generating JSON for ", goos)
if err := updates.CreateJSONAndSign(dir, goos); err != nil {
log.Error(err)
}
}
}
func makeMemoryProfile() {
name := "./mem.pprof"
f, err := os.Create(name)
if err != nil {
log.Error("Could not create memory profile: ", err)
}
if abs, err := filepath.Abs(name); err == nil {
name = abs
}
log.Info("Writing memory profile to ", name)
runtime.GC() // get up-to-date statistics
if err := pprof.WriteHeapProfile(f); err != nil {
log.Error("Could not write memory profile: ", err)
}
_ = f.Close()
}
// filterRestartNumberFromArgs removes flag with a number how many restart we already did.
// See restartApp how that number is used.
func filterRestartNumberFromArgs() {
tmp := os.Args[:0]
for i, arg := range os.Args {
if !strings.HasPrefix(arg, "--restart_") {
tmp = append(tmp, arg)
continue
}
var err error
numberOfCrashes, err = strconv.Atoi(os.Args[i][10:])
if err != nil {
numberOfCrashes = maxAllowedCrashes
}
}
os.Args = tmp
}
// restartApp starts a new instance in background.
func restartApp() {
if numberOfCrashes >= maxAllowedCrashes {
log.Error("Too many crashes")
return
}
if exeFile, err := os.Executable(); err == nil {
arguments := append(os.Args[1:], fmt.Sprintf("--restart_%d", numberOfCrashes))
cmd := exec.Command(exeFile, arguments...) //nolint[gosec]
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
if err := cmd.Start(); err != nil {
log.Error("Restart failed: ", err)
}
}
}

View File

@ -18,108 +18,40 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"runtime/pprof"
"strconv"
"strings"
"github.com/ProtonMail/proton-bridge/internal/cmd"
"github.com/ProtonMail/proton-bridge/internal/events"
"github.com/ProtonMail/proton-bridge/internal/frontend"
"github.com/ProtonMail/proton-bridge/internal/importexport"
"github.com/ProtonMail/proton-bridge/internal/users/credentials"
"github.com/ProtonMail/proton-bridge/pkg/args"
"github.com/ProtonMail/proton-bridge/pkg/config"
"github.com/ProtonMail/proton-bridge/pkg/constants"
"github.com/ProtonMail/proton-bridge/pkg/listener"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
"github.com/ProtonMail/proton-bridge/pkg/updates"
"github.com/getsentry/raven-go"
"github.com/allan-simon/go-singleinstance"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
)
const (
appName = "importExport"
appNameDash = "import-export"
)
var (
log = logrus.WithField("pkg", "main") //nolint[gochecknoglobals]
// How many crashes in a row.
numberOfCrashes = 0 //nolint[gochecknoglobals]
// After how many crashes import/export gives up starting.
maxAllowedCrashes = 10 //nolint[gochecknoglobals]
)
func main() {
constants.AppShortName = "importExport" //TODO
if err := raven.SetDSN(constants.DSNSentry); err != nil {
log.WithError(err).Errorln("Can not setup sentry DSN")
}
raven.SetRelease(constants.Revision)
args.FilterProcessSerialNumberFromArgs()
filterRestartNumberFromArgs()
app := cli.NewApp()
app.Name = "Protonmail Import/Export"
app.Version = constants.BuildVersion
app.Flags = []cli.Flag{
cli.StringFlag{
Name: "log-level, l",
Usage: "Set the log level (one of panic, fatal, error, warn, info, debug, debug-client, debug-server)"},
cli.BoolFlag{
Name: "cli, c",
Usage: "Use command line interface"},
cli.StringFlag{
Name: "version-json, g",
Usage: "Generate json version file"},
cli.BoolFlag{
Name: "mem-prof, m",
Usage: "Generate memory profile"},
cli.BoolFlag{
Name: "cpu-prof, p",
Usage: "Generate CPU profile"},
}
app.Usage = "ProtonMail Import/Export"
app.Action = run
// Always log the basic info about current import/export.
logrus.SetLevel(logrus.InfoLevel)
log.WithField("version", constants.Version).
WithField("revision", constants.Revision).
WithField("runtime", runtime.GOOS).
WithField("build", constants.BuildTime).
WithField("args", os.Args).
WithField("appLong", app.Name).
WithField("appShort", constants.AppShortName).
Info("Run app")
if err := app.Run(os.Args); err != nil {
log.Error("Program exited with error: ", err)
}
}
type panicHandler struct {
cfg *config.Config
err *error // Pointer to error of cli action.
}
func (ph *panicHandler) HandlePanic() {
r := recover()
if r == nil {
return
}
config.HandlePanic(ph.cfg, fmt.Sprintf("Recover: %v", r))
frontend.HandlePanic("ProtonMail Import-Export")
*ph.err = cli.NewExitError("Panic and restart", 255)
numberOfCrashes++
log.Error("Restarting after panic")
restartApp()
os.Exit(255)
cmd.Main(
"ProtonMail Import/Export",
"ProtonMail Import/Export tool",
nil,
run,
)
}
// run initializes and starts everything in a precise order.
@ -127,13 +59,17 @@ func (ph *panicHandler) HandlePanic() {
// IMPORTANT: ***Read the comments before CHANGING the order ***
func run(context *cli.Context) (contextError error) { // nolint[funlen]
// We need to have config instance to setup a logs, panic handler, etc ...
cfg := config.New(constants.AppShortName, constants.Version, constants.Revision, "")
cfg := config.New(appName, constants.Version, constants.Revision, "")
// We want to know about any problem. Our PanicHandler calls sentry which is
// not dependent on anything else. If that fails, it tries to create crash
// report which will not be possible if no folder can be created. That's the
// only problem we will not be notified about in any way.
panicHandler := &panicHandler{cfg, &contextError}
panicHandler := &cmd.PanicHandler{
AppName: "ProtonMail Import/Export",
Config: cfg,
Err: &contextError,
}
defer panicHandler.HandlePanic()
// First we need config and create necessary folder; it's dependency for everything.
@ -154,21 +90,22 @@ func run(context *cli.Context) (contextError error) { // nolint[funlen]
// It's safe to get version JSON file even when other instance is running.
// (thus we put it before check of presence of other Import/Export instance).
updates := updates.New(
constants.AppShortName,
constants.Version,
constants.Revision,
constants.BuildTime,
importexport.ReleaseNotes,
importexport.ReleaseFixedBugs,
cfg.GetUpdateDir(),
)
updates := updates.NewImportExport(cfg.GetUpdateDir())
if dir := context.GlobalString("version-json"); dir != "" {
generateVersionFiles(updates, dir)
cmd.GenerateVersionFiles(updates, dir)
return nil
}
// Now we can try to proceed with starting the import/export. First we need to ensure
// this is the only instance. If not, we will end and focus the existing one.
lock, err := singleinstance.CreateLockFile(cfg.GetLockPath())
if err != nil {
log.Warn("Import/Export is already running")
return cli.NewExitError("Import/Export is already running.", 3)
}
defer lock.Close() //nolint[errcheck]
// In case user wants to do CPU or memory profiles...
if doCPUProfile := context.GlobalBool("cpu-prof"); doCPUProfile {
f, err := os.Create("cpu.pprof")
@ -182,7 +119,7 @@ func run(context *cli.Context) (contextError error) { // nolint[funlen]
}
if doMemoryProfile := context.GlobalBool("mem-prof"); doMemoryProfile {
defer makeMemoryProfile()
defer cmd.MakeMemoryProfile()
}
// Now we initialize all Import/Export parts.
@ -190,7 +127,7 @@ func run(context *cli.Context) (contextError error) { // nolint[funlen]
eventListener := listener.New()
events.SetupEvents(eventListener)
credentialsStore, credentialsError := credentials.NewStore("import-export")
credentialsStore, credentialsError := credentials.NewStore(appNameDash)
if credentialsError != nil {
log.Error("Could not get credentials store: ", credentialsError)
}
@ -224,73 +161,8 @@ func run(context *cli.Context) (contextError error) { // nolint[funlen]
}
if frontend.IsAppRestarting() {
restartApp()
cmd.RestartApp()
}
return nil
}
// generateVersionFiles writes a JSON file with details about current build.
// Those files are used for upgrading the app.
func generateVersionFiles(updates *updates.Updates, dir string) {
log.Info("Generating version files")
for _, goos := range []string{"windows", "darwin", "linux"} {
log.Debug("Generating JSON for ", goos)
if err := updates.CreateJSONAndSign(dir, goos); err != nil {
log.Error(err)
}
}
}
func makeMemoryProfile() {
name := "./mem.pprof"
f, err := os.Create(name)
if err != nil {
log.Error("Could not create memory profile: ", err)
}
if abs, err := filepath.Abs(name); err == nil {
name = abs
}
log.Info("Writing memory profile to ", name)
runtime.GC() // get up-to-date statistics
if err := pprof.WriteHeapProfile(f); err != nil {
log.Error("Could not write memory profile: ", err)
}
_ = f.Close()
}
// filterRestartNumberFromArgs removes flag with a number how many restart we already did.
// See restartApp how that number is used.
func filterRestartNumberFromArgs() {
tmp := os.Args[:0]
for i, arg := range os.Args {
if !strings.HasPrefix(arg, "--restart_") {
tmp = append(tmp, arg)
continue
}
var err error
numberOfCrashes, err = strconv.Atoi(os.Args[i][10:])
if err != nil {
numberOfCrashes = maxAllowedCrashes
}
}
os.Args = tmp
}
// restartApp starts a new instance in background.
func restartApp() {
if numberOfCrashes >= maxAllowedCrashes {
log.Error("Too many crashes")
return
}
if exeFile, err := os.Executable(); err == nil {
arguments := append(os.Args[1:], fmt.Sprintf("--restart_%d", numberOfCrashes))
cmd := exec.Command(exeFile, arguments...) //nolint[gosec]
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
if err := cmd.Start(); err != nil {
log.Error("Restart failed: ", err)
}
}
}

1
go.mod
View File

@ -35,6 +35,7 @@ require (
github.com/emersion/go-imap-specialuse v0.0.0-20200722111535-598ff00e4075
github.com/emersion/go-imap-unselect v0.0.0-20171113212723-b985794e5f26
github.com/emersion/go-mbox v1.0.0
github.com/emersion/go-message v0.11.1
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe
github.com/emersion/go-vcard v0.0.0-20190105225839-8856043f13c5 // indirect

View File

@ -15,16 +15,16 @@
// You should have received a copy of the GNU General Public License
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package args
package cmd
import (
"os"
"strings"
)
// FilterProcessSerialNumberFromArgs removes additional flag from MacOS. More info ProcessSerialNumber
// filterProcessSerialNumberFromArgs removes additional flag from MacOS. More info ProcessSerialNumber
// http://mirror.informatimago.com/next/developer.apple.com/documentation/Carbon/Reference/Process_Manager/prmref_main/data_type_5.html#//apple_ref/doc/uid/TP30000208/C001951
func FilterProcessSerialNumberFromArgs() {
func filterProcessSerialNumberFromArgs() {
tmp := os.Args[:0]
for _, arg := range os.Args {
if !strings.Contains(arg, "-psn_") {

86
internal/cmd/main.go Normal file
View File

@ -0,0 +1,86 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.
//
// ProtonMail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// ProtonMail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package cmd
import (
"os"
"runtime"
"github.com/ProtonMail/proton-bridge/pkg/constants"
"github.com/getsentry/raven-go"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
)
var (
log = logrus.WithField("pkg", "cmd") //nolint[gochecknoglobals]
baseFlags = []cli.Flag{ //nolint[gochecknoglobals]
cli.StringFlag{
Name: "log-level, l",
Usage: "Set the log level (one of panic, fatal, error, warn, info, debug, debug-client, debug-server)"},
cli.BoolFlag{
Name: "cli, c",
Usage: "Use command line interface"},
cli.StringFlag{
Name: "version-json, g",
Usage: "Generate json version file"},
cli.BoolFlag{
Name: "mem-prof, m",
Usage: "Generate memory profile"},
cli.BoolFlag{
Name: "cpu-prof, p",
Usage: "Generate CPU profile"},
}
)
// Main sets up Sentry, filters out unwanted args, creates app and runs it.
func Main(appName, usage string, extraFlags []cli.Flag, run func(*cli.Context) error) {
if err := raven.SetDSN(constants.DSNSentry); err != nil {
log.WithError(err).Errorln("Can not setup sentry DSN")
}
raven.SetRelease(constants.Revision)
filterProcessSerialNumberFromArgs()
filterRestartNumberFromArgs()
app := newApp(appName, usage, extraFlags, run)
logrus.SetLevel(logrus.InfoLevel)
log.WithField("version", constants.Version).
WithField("revision", constants.Revision).
WithField("build", constants.BuildTime).
WithField("runtime", runtime.GOOS).
WithField("args", os.Args).
WithField("appName", app.Name).
Info("Run app")
if err := app.Run(os.Args); err != nil {
log.Error("Program exited with error: ", err)
}
}
func newApp(appName, usage string, extraFlags []cli.Flag, run func(*cli.Context) error) *cli.App {
app := cli.NewApp()
app.Name = appName
app.Usage = usage
app.Version = constants.BuildVersion
app.Flags = append(baseFlags, extraFlags...) //nolint[gocritic]
app.Action = run
return app
}

View File

@ -0,0 +1,43 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.
//
// ProtonMail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// ProtonMail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package cmd
import (
"os"
"path/filepath"
"runtime"
"runtime/pprof"
)
// MakeMemoryProfile generates memory pprof.
func MakeMemoryProfile() {
name := "./mem.pprof"
f, err := os.Create(name)
if err != nil {
log.Error("Could not create memory profile: ", err)
}
if abs, err := filepath.Abs(name); err == nil {
name = abs
}
log.Info("Writing memory profile to ", name)
runtime.GC() // get up-to-date statistics
if err := pprof.WriteHeapProfile(f); err != nil {
log.Error("Could not write memory profile: ", err)
}
_ = f.Close()
}

108
internal/cmd/restart.go Normal file
View File

@ -0,0 +1,108 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.
//
// ProtonMail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// ProtonMail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package cmd
import (
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"github.com/ProtonMail/proton-bridge/internal/frontend"
"github.com/ProtonMail/proton-bridge/pkg/config"
"github.com/urfave/cli"
)
const (
// After how many crashes app gives up starting.
maxAllowedCrashes = 10
)
var (
// How many crashes happened so far in a row.
// It will be filled from args by `filterRestartNumberFromArgs`.
// Every call of `HandlePanic` will increase this number.
// Then it will be passed as argument to the next try by `RestartApp`.
numberOfCrashes = 0 //nolint[gochecknoglobals]
)
// filterRestartNumberFromArgs removes flag with a number how many restart we already did.
// See restartApp how that number is used.
func filterRestartNumberFromArgs() {
tmp := os.Args[:0]
for i, arg := range os.Args {
if !strings.HasPrefix(arg, "--restart_") {
tmp = append(tmp, arg)
continue
}
var err error
numberOfCrashes, err = strconv.Atoi(os.Args[i][10:])
if err != nil {
numberOfCrashes = maxAllowedCrashes
}
}
os.Args = tmp
}
// DisableRestart disables restart once `RestartApp` is called.
func DisableRestart() {
numberOfCrashes = maxAllowedCrashes
}
// RestartApp starts a new instance in background.
func RestartApp() {
if numberOfCrashes >= maxAllowedCrashes {
log.Error("Too many crashes")
return
}
if exeFile, err := os.Executable(); err == nil {
arguments := append(os.Args[1:], fmt.Sprintf("--restart_%d", numberOfCrashes))
cmd := exec.Command(exeFile, arguments...) //nolint[gosec]
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
if err := cmd.Start(); err != nil {
log.Error("Restart failed: ", err)
}
}
}
// PanicHandler defines HandlePanic which can be used anywhere in defer.
type PanicHandler struct {
AppName string
Config *config.Config
Err *error // Pointer to error of cli action.
}
// HandlePanic should be called in defer to ensure restart of app after error.
func (ph *PanicHandler) HandlePanic() {
r := recover()
if r == nil {
return
}
config.HandlePanic(ph.Config, fmt.Sprintf("Recover: %v", r))
frontend.HandlePanic(ph.AppName)
*ph.Err = cli.NewExitError("Panic and restart", 255)
numberOfCrashes++
log.Error("Restarting after panic")
RestartApp()
os.Exit(255)
}

View File

@ -0,0 +1,32 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.
//
// ProtonMail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// ProtonMail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package cmd
import "github.com/ProtonMail/proton-bridge/pkg/updates"
// GenerateVersionFiles writes a JSON file with details about current build.
// Those files are used for upgrading the app.
func GenerateVersionFiles(updates *updates.Updates, dir string) {
log.Info("Generating version files")
for _, goos := range []string{"windows", "darwin", "linux"} {
log.Debug("Generating JSON for ", goos)
if err := updates.CreateJSONAndSign(dir, goos); err != nil {
log.Error(err)
}
}
}

View File

@ -15,7 +15,7 @@
// You should have received a copy of the GNU General Public License
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
// Package cli provides CLI interface of the Bridge.
// Package cli provides CLI interface of the Import/Export.
package cli
import (
@ -227,7 +227,11 @@ func (f *frontendCLI) Loop(credentialsError error) error {
return credentialsError
}
f.Print(`Welcome to ProtonMail Import/Export interactive shell`)
f.Print(`
Welcome to ProtonMail Import/Export interactive shell
WARNING: CLI is experimental feature and does not cover all functionality yet.
`)
f.Run()
return nil
}

View File

@ -20,7 +20,7 @@ package cli
import (
"strings"
"github.com/ProtonMail/proton-bridge/internal/bridge"
"github.com/ProtonMail/proton-bridge/internal/importexport"
"github.com/ProtonMail/proton-bridge/pkg/updates"
"github.com/abiosoft/ishell"
)
@ -47,7 +47,7 @@ func (f *frontendCLI) printLocalReleaseNotes(c *ishell.Context) {
}
func (f *frontendCLI) printReleaseNotes(versionInfo updates.VersionInfo) {
f.Println(bold("ProtonMail Bridge "+versionInfo.Version), "\n")
f.Println(bold("ProtonMail Import/Export "+versionInfo.Version), "\n")
if versionInfo.ReleaseNotes != "" {
f.Println(bold("Release Notes"))
f.Println(versionInfo.ReleaseNotes)
@ -59,7 +59,7 @@ func (f *frontendCLI) printReleaseNotes(versionInfo updates.VersionInfo) {
}
func (f *frontendCLI) printCredits(c *ishell.Context) {
for _, pkg := range strings.Split(bridge.Credits, ";") {
for _, pkg := range strings.Split(importexport.Credits, ";") {
f.Println(pkg)
}
}

View File

@ -98,7 +98,7 @@ func (f *frontendCLI) notifyNeedUpgrade() {
func (f *frontendCLI) notifyCredentialsError() {
// Print in 80-column width.
f.Println("ProtonMail Bridge is not able to detect a supported password manager")
f.Println("ProtonMail Import/Export is not able to detect a supported password manager")
f.Println("(pass, gnome-keyring). Please install and set up a supported password manager")
f.Println("and restart the application.")
}
@ -109,7 +109,7 @@ func (f *frontendCLI) notifyCertIssue() {
be insecure.
Description:
ProtonMail Bridge was not able to establish a secure connection to Proton
ProtonMail Import/Export was not able to establish a secure connection to Proton
servers due to a TLS certificate error. This means your connection may
potentially be insecure and susceptible to monitoring by third parties.

View File

@ -47,7 +47,7 @@ func (f *frontendCLI) printLocalReleaseNotes(c *ishell.Context) {
}
func (f *frontendCLI) printReleaseNotes(versionInfo updates.VersionInfo) {
f.Println(bold("ProtonMail Import/Export "+versionInfo.Version), "\n")
f.Println(bold("ProtonMail Bridge "+versionInfo.Version), "\n")
if versionInfo.ReleaseNotes != "" {
f.Println(bold("Release Notes"))
f.Println(versionInfo.ReleaseNotes)

View File

@ -67,12 +67,12 @@ func CheckPathStatus(path string) int {
tmpFile += "tmp"
_, err = os.Lstat(tmpFile)
}
err = os.Mkdir(tmpFile, 0777)
err = os.Mkdir(tmpFile, 0750)
if err != nil {
stat |= PathWrongPermissions
return int(stat)
}
os.Remove(tmpFile)
_ = os.Remove(tmpFile)
} else {
stat |= PathNotADir
}

View File

@ -15,8 +15,8 @@
// You should have received a copy of the GNU General Public License
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
// Code generated by ./credits.sh at Thu 04 Jun 2020 04:19:16 PM CEST. DO NOT EDIT.
// Code generated by ./credits.sh at Mon Jul 13 14:02:21 CEST 2020. DO NOT EDIT.
package importexport
const Credits = "github.com/0xAX/notificator;github.com/abiosoft/ishell;github.com/abiosoft/readline;github.com/allan-simon/go-singleinstance;github.com/andybalholm/cascadia;github.com/certifi/gocertifi;github.com/chzyer/logex;github.com/chzyer/test;github.com/cucumber/godog;github.com/docker/docker-credential-helpers;github.com/emersion/go-imap;github.com/emersion/go-imap-appendlimit;github.com/emersion/go-imap-idle;github.com/emersion/go-imap-move;github.com/emersion/go-imap-quota;github.com/emersion/go-imap-specialuse;github.com/emersion/go-imap-unselect;github.com/emersion/go-mbox;github.com/emersion/go-sasl;github.com/emersion/go-smtp;github.com/emersion/go-textwrapper;github.com/emersion/go-vcard;github.com/fatih/color;github.com/flynn-archive/go-shlex;github.com/getsentry/raven-go;github.com/golang/mock;github.com/google/go-cmp;github.com/gopherjs/gopherjs;github.com/go-resty/resty/v2;github.com/hashicorp/go-multierror;github.com/jameskeane/bcrypt;github.com/jaytaylor/html2text;github.com/jhillyerd/enmime;github.com/kardianos/osext;github.com/keybase/go-keychain;github.com/logrusorgru/aurora;github.com/miekg/dns;github.com/myesui/uuid;github.com/nsf/jsondiff;github.com/pkg/errors;github.com/ProtonMail/bcrypt;github.com/ProtonMail/crypto;github.com/ProtonMail/docker-credential-helpers;github.com/ProtonMail/go-appdir;github.com/ProtonMail/go-apple-mobileconfig;github.com/ProtonMail/go-autostart;github.com/ProtonMail/go-imap-id;github.com/ProtonMail/gopenpgp;github.com/ProtonMail/go-smtp;github.com/ProtonMail/go-vcard;github.com/sirupsen/logrus;github.com/skratchdot/open-golang;github.com/stretchr/testify;github.com/therecipe/qt;github.com/twinj/uuid;github.com/urfave/cli;go.etcd.io/bbolt;golang.org/x/crypto;golang.org/x/net;golang.org/x/text;gopkg.in/stretchr/testify.v1;;Font Awesome 4.7.0;;Qt 5.13 by Qt group;"
const Credits = "github.com/0xAX/notificator;github.com/ProtonMail/bcrypt;github.com/ProtonMail/crypto;github.com/ProtonMail/docker-credential-helpers;github.com/ProtonMail/go-appdir;github.com/ProtonMail/go-apple-mobileconfig;github.com/ProtonMail/go-autostart;github.com/ProtonMail/go-imap-id;github.com/ProtonMail/go-smtp;github.com/ProtonMail/go-vcard;github.com/ProtonMail/gopenpgp/v2;github.com/abiosoft/ishell;github.com/abiosoft/readline;github.com/allan-simon/go-singleinstance;github.com/andybalholm/cascadia;github.com/certifi/gocertifi;github.com/chzyer/logex;github.com/chzyer/test;github.com/cucumber/godog;github.com/docker/docker-credential-helpers;github.com/emersion/go-imap;github.com/emersion/go-imap-appendlimit;github.com/emersion/go-imap-idle;github.com/emersion/go-imap-move;github.com/emersion/go-imap-quota;github.com/emersion/go-imap-specialuse;github.com/emersion/go-imap-unselect;github.com/emersion/go-mbox;github.com/emersion/go-message;github.com/emersion/go-sasl;github.com/emersion/go-smtp;github.com/emersion/go-textwrapper;github.com/emersion/go-vcard;github.com/fatih/color;github.com/flynn-archive/go-shlex;github.com/getsentry/raven-go;github.com/go-resty/resty/v2;github.com/golang/mock;github.com/google/go-cmp;github.com/gopherjs/gopherjs;github.com/hashicorp/go-multierror;github.com/jameskeane/bcrypt;github.com/jaytaylor/html2text;github.com/jhillyerd/enmime;github.com/kardianos/osext;github.com/keybase/go-keychain;github.com/logrusorgru/aurora;github.com/miekg/dns;github.com/myesui/uuid;github.com/nsf/jsondiff;github.com/pkg/errors;github.com/sirupsen/logrus;github.com/skratchdot/open-golang;github.com/stretchr/testify;github.com/therecipe/qt;github.com/twinj/uuid;github.com/urfave/cli;go.etcd.io/bbolt;golang.org/x/crypto;golang.org/x/net;golang.org/x/text;gopkg.in/stretchr/testify.v1;;Font Awesome 4.7.0;;Qt 5.13 by Qt group;"

View File

@ -15,7 +15,7 @@
// You should have received a copy of the GNU General Public License
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
// Code generated by ./release-notes.sh at 'Thu Jun 4 15:54:31 CEST 2020'. DO NOT EDIT.
// Code generated by ./release-notes.sh at 'Thu Jun 25 10:06:16 CEST 2020'. DO NOT EDIT.
package importexport

View File

@ -41,13 +41,9 @@ func (m Mailbox) Hash() string {
// LeastUsedColor is intended to return color for creating a new inbox or label
func LeastUsedColor(mailboxes []Mailbox) string {
usedColors := []string{}
if mailboxes != nil {
for _, m := range mailboxes {
usedColors = append(usedColors, m.Color)
}
for _, m := range mailboxes {
usedColors = append(usedColors, m.Color)
}
return pmapi.LeastUsedColor(usedColors)
}

View File

@ -56,6 +56,10 @@ type MessageStatus struct {
Time time.Time
}
func (status *MessageStatus) String() string {
return fmt.Sprintf("%s (%s, %s, %s): %s", status.SourceID, status.Subject, status.From, status.Time, status.GetErrorMessage())
}
func (status *MessageStatus) setDetailsFromHeader(header mail.Header) {
dec := &mime.WordDecoder{}

View File

@ -139,8 +139,10 @@ func (p *Progress) messageExported(messageID string, body []byte, err error) {
p.log.WithField("id", messageID).WithError(err).Debug("Message exported")
status := p.messageStatuses[messageID]
status.exported = true
status.exportErr = err
if err == nil {
status.exported = true
}
if len(body) > 0 {
status.bodyHash = fmt.Sprintf("%x", sha256.Sum256(body))
@ -166,8 +168,10 @@ func (p *Progress) messageImported(messageID, importID string, err error) {
p.log.WithField("id", messageID).WithError(err).Debug("Message imported")
p.messageStatuses[messageID].targetID = importID
p.messageStatuses[messageID].imported = true
p.messageStatuses[messageID].importErr = err
if err == nil {
p.messageStatuses[messageID].imported = true
}
// Import is the last step, now we can log the result to the report file.
p.logMessage(messageID)

View File

@ -73,8 +73,8 @@ func TestProgressAddingMessages(t *testing.T) {
failed, imported, exported, added, _ := progress.GetCounts()
a.Equal(t, uint(4), added)
a.Equal(t, uint(4), exported)
a.Equal(t, uint(3), imported)
a.Equal(t, uint(2), exported)
a.Equal(t, uint(2), imported)
a.Equal(t, uint(3), failed)
errorsMap := map[string]string{}

View File

@ -68,7 +68,7 @@ func (p *IMAPProvider) loadMessageInfoMap(rules transferRules, progress *Progres
continue
}
messagesInfo := p.loadMessagesInfo(rule, progress, mailbox.UidValidity)
messagesInfo := p.loadMessagesInfo(rule, progress, mailbox.UidValidity, mailbox.Messages)
res[rule.SourceMailbox.Name] = messagesInfo
progress.updateCount(rule.SourceMailbox.Name, uint(len(messagesInfo)))
}
@ -76,7 +76,7 @@ func (p *IMAPProvider) loadMessageInfoMap(rules transferRules, progress *Progres
return res
}
func (p *IMAPProvider) loadMessagesInfo(rule *Rule, progress *Progress, uidValidity uint32) map[string]imapMessageInfo {
func (p *IMAPProvider) loadMessagesInfo(rule *Rule, progress *Progress, uidValidity, count uint32) map[string]imapMessageInfo {
messagesInfo := map[string]imapMessageInfo{}
pageStart := uint32(1)
@ -86,6 +86,11 @@ func (p *IMAPProvider) loadMessagesInfo(rule *Rule, progress *Progress, uidValid
break
}
// Some servers do not accept message sequence number higher than the total count.
if pageEnd > count {
pageEnd = count
}
seqSet := &imap.SeqSet{}
seqSet.AddRange(pageStart, pageEnd)
@ -114,7 +119,7 @@ func (p *IMAPProvider) loadMessagesInfo(rule *Rule, progress *Progress, uidValid
}
progress.callWrap(func() error {
return p.fetch(seqSet, items, processMessageCallback)
return p.fetch(rule.SourceMailbox.Name, seqSet, items, processMessageCallback)
})
if pageMsgCount < imapPageSize {
@ -145,7 +150,6 @@ func (p *IMAPProvider) transferTo(rule *Rule, messagesInfo map[string]imapMessag
if seqSetSize != 0 && (seqSetSize+messageInfo.size) > imapMaxFetchSize {
log.WithField("mailbox", rule.SourceMailbox.Name).WithField("seq", seqSet).WithField("size", seqSetSize).Debug("Fetching messages")
p.exportMessages(rule, progress, ch, seqSet, uidToID)
seqSet = &imap.SeqSet{}
@ -157,6 +161,11 @@ func (p *IMAPProvider) transferTo(rule *Rule, messagesInfo map[string]imapMessag
seqSetSize += messageInfo.size
uidToID[messageInfo.uid] = messageInfo.id
}
if len(uidToID) != 0 {
log.WithField("mailbox", rule.SourceMailbox.Name).WithField("seq", seqSet).WithField("size", seqSetSize).Debug("Fetching messages")
p.exportMessages(rule, progress, ch, seqSet, uidToID)
}
}
func (p *IMAPProvider) exportMessages(rule *Rule, progress *Progress, ch chan<- Message, seqSet *imap.SeqSet, uidToID map[uint32]string) {
@ -188,7 +197,7 @@ func (p *IMAPProvider) exportMessages(rule *Rule, progress *Progress, ch chan<-
}
progress.callWrap(func() error {
return p.uidFetch(seqSet, items, processMessageCallback)
return p.uidFetch(rule.SourceMailbox.Name, seqSet, items, processMessageCallback)
})
}

View File

@ -49,16 +49,11 @@ func (l *imapErrorLogger) Println(v ...interface{}) {
l.log.Errorln(v...)
}
type imapDebugLogger struct { //nolint[unused]
log *logrus.Entry
}
func (l *imapDebugLogger) Write(data []byte) (int, error) {
l.log.Trace(string(data))
return len(data), nil
}
func (p *IMAPProvider) ensureConnection(callback func() error) error {
return p.ensureConnectionAndSelection(callback, "")
}
func (p *IMAPProvider) ensureConnectionAndSelection(callback func() error, ensureSelectedIn string) error {
var callErr error
for i := 1; i <= imapRetries; i++ {
callErr = callback()
@ -66,8 +61,8 @@ func (p *IMAPProvider) ensureConnection(callback func() error) error {
return nil
}
log.WithField("attempt", i).WithError(callErr).Warning("Call failed, trying reconnect")
err := p.tryReconnect()
log.WithField("attempt", i).WithError(callErr).Warning("IMAP call failed, trying reconnect")
err := p.tryReconnect(ensureSelectedIn)
if err != nil {
return err
}
@ -75,7 +70,7 @@ func (p *IMAPProvider) ensureConnection(callback func() error) error {
return errors.Wrap(callErr, "too many retries")
}
func (p *IMAPProvider) tryReconnect() error {
func (p *IMAPProvider) tryReconnect(ensureSelectedIn string) error {
start := time.Now()
var previousErr error
for {
@ -84,6 +79,7 @@ func (p *IMAPProvider) tryReconnect() error {
}
err := pmapi.CheckConnection()
log.WithError(err).Debug("Connection check")
if err != nil {
time.Sleep(imapReconnectSleep)
previousErr = err
@ -91,24 +87,47 @@ func (p *IMAPProvider) tryReconnect() error {
}
err = p.reauth()
log.WithError(err).Debug("Reauth")
if err != nil {
time.Sleep(imapReconnectSleep)
previousErr = err
continue
}
if ensureSelectedIn != "" {
_, err = p.client.Select(ensureSelectedIn, true)
log.WithError(err).Debug("Reselect")
if err != nil {
previousErr = err
continue
}
}
break
}
return nil
}
func (p *IMAPProvider) reauth() error {
if _, err := p.client.Capability(); err != nil {
state := p.client.State()
log.WithField("addr", p.addr).WithField("state", state).WithError(err).Debug("Reconnecting")
p.client = nil
var state imap.ConnState
// In some cases once go-imap fails, we cannot issue another command
// because it would dead-lock. Let's simply ignore it, we want to open
// new connection anyway.
ch := make(chan struct{})
go func() {
defer close(ch)
if _, err := p.client.Capability(); err != nil {
state = p.client.State()
}
}()
select {
case <-ch:
case <-time.After(30 * time.Second):
}
log.WithField("addr", p.addr).WithField("state", state).Debug("Reconnecting")
p.client = nil
return p.auth()
}
@ -121,15 +140,25 @@ func (p *IMAPProvider) auth() error { //nolint[funlen]
return errors.Wrap(err, "failed to dial server")
}
client, err := imapClient.DialTLS(p.addr, nil)
var client *imapClient.Client
var err error
host, _, _ := net.SplitHostPort(p.addr)
if host == "127.0.0.1" {
client, err = imapClient.Dial(p.addr)
} else {
client, err = imapClient.DialTLS(p.addr, nil)
}
if err != nil {
return errors.Wrap(err, "failed to connect to server")
}
client.ErrorLog = &imapErrorLogger{logrus.WithField("pkg", "imap-client")}
// Logrus have Writer helper but it fails for big messages because of
// bufio.MaxScanTokenSize limit.
// This spams a lot, uncomment once needed during development.
//client.SetDebug(&imapDebugLogger{logrus.WithField("pkg", "imap-client")})
// Logrus `WriterLevel` fails for big messages because of bufio.MaxScanTokenSize limit.
// Also, this spams a lot, uncomment once needed during development.
//client.SetDebug(imap.NewDebugWriter(
// logrus.WithField("pkg", "imap/client").WriterLevel(logrus.TraceLevel),
// logrus.WithField("pkg", "imap/server").WriterLevel(logrus.TraceLevel),
//))
p.client = client
log.Info("Connected")
@ -205,16 +234,16 @@ func (p *IMAPProvider) selectIn(mailboxName string) (mailbox *imap.MailboxStatus
return
}
func (p *IMAPProvider) fetch(seqSet *imap.SeqSet, items []imap.FetchItem, processMessageCallback func(m *imap.Message)) error {
return p.fetchHelper(false, seqSet, items, processMessageCallback)
func (p *IMAPProvider) fetch(ensureSelectedIn string, seqSet *imap.SeqSet, items []imap.FetchItem, processMessageCallback func(m *imap.Message)) error {
return p.fetchHelper(false, ensureSelectedIn, seqSet, items, processMessageCallback)
}
func (p *IMAPProvider) uidFetch(seqSet *imap.SeqSet, items []imap.FetchItem, processMessageCallback func(m *imap.Message)) error {
return p.fetchHelper(true, seqSet, items, processMessageCallback)
func (p *IMAPProvider) uidFetch(ensureSelectedIn string, seqSet *imap.SeqSet, items []imap.FetchItem, processMessageCallback func(m *imap.Message)) error {
return p.fetchHelper(true, ensureSelectedIn, seqSet, items, processMessageCallback)
}
func (p *IMAPProvider) fetchHelper(uid bool, seqSet *imap.SeqSet, items []imap.FetchItem, processMessageCallback func(m *imap.Message)) error {
return p.ensureConnection(func() error {
func (p *IMAPProvider) fetchHelper(uid bool, ensureSelectedIn string, seqSet *imap.SeqSet, items []imap.FetchItem, processMessageCallback func(m *imap.Message)) error {
return p.ensureConnectionAndSelection(func() error {
messagesCh := make(chan *imap.Message)
doneCh := make(chan error)
@ -232,5 +261,5 @@ func (p *IMAPProvider) fetchHelper(uid bool, seqSet *imap.SeqSet, items []imap.F
err := <-doneCh
return err
})
}, ensureSelectedIn)
}

View File

@ -45,7 +45,7 @@ func (p *MBOXProvider) TransferFrom(rules transferRules, progress *Progress, ch
defer log.Info("Finished transfer from channel to MBOX")
for msg := range ch {
for progress.shouldStop() {
if progress.shouldStop() {
break
}

View File

@ -20,7 +20,7 @@ package transfer
import (
"sort"
"github.com/ProtonMail/gopenpgp/crypto"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
"github.com/pkg/errors"
)
@ -31,6 +31,9 @@ type PMAPIProvider struct {
userID string
addressID string
keyRing *crypto.KeyRing
importMsgReqMap map[string]*pmapi.ImportMsgReq // Key is msg transfer ID.
importMsgReqSize int
}
// NewPMAPIProvider returns new PMAPIProvider.
@ -45,6 +48,9 @@ func NewPMAPIProvider(clientManager ClientManager, userID, addressID string) (*P
userID: userID,
addressID: addressID,
keyRing: keyRing,
importMsgReqMap: map[string]*pmapi.ImportMsgReq{},
importMsgReqSize: 0,
}, nil
}

View File

@ -19,6 +19,7 @@ package transfer
import (
"fmt"
"sync"
pkgMessage "github.com/ProtonMail/proton-bridge/pkg/message"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
@ -32,11 +33,21 @@ func (p *PMAPIProvider) TransferTo(rules transferRules, progress *Progress, ch c
log.Info("Started transfer from PMAPI to channel")
defer log.Info("Finished transfer from PMAPI to channel")
go p.loadCounts(rules, progress)
// TransferTo cannot end sooner than loadCounts goroutine because
// loadCounts writes to channel in progress which would be closed.
// That can happen for really small accounts.
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
p.loadCounts(rules, progress)
}()
for rule := range rules.iterateActiveRules() {
p.transferTo(rule, progress, ch, rules.skipEncryptedMessages)
}
wg.Wait()
}
func (p *PMAPIProvider) loadCounts(rules transferRules, progress *Progress) {

View File

@ -28,6 +28,11 @@ import (
"github.com/pkg/errors"
)
const (
pmapiImportBatchMaxItems = 10
pmapiImportBatchMaxSize = 25 * 1000 * 1000 // 25 MB
)
// DefaultMailboxes returns the default mailboxes for default rules if no other is found.
func (p *PMAPIProvider) DefaultMailboxes(_ Mailbox) []Mailbox {
return []Mailbox{{
@ -67,18 +72,19 @@ func (p *PMAPIProvider) TransferFrom(rules transferRules, progress *Progress, ch
defer log.Info("Finished transfer from channel to PMAPI")
for msg := range ch {
for progress.shouldStop() {
if progress.shouldStop() {
break
}
var importedID string
var err error
if p.isMessageDraft(msg) {
importedID, err = p.importDraft(msg, rules.globalMailbox)
p.transferDraft(rules, progress, msg)
} else {
importedID, err = p.importMessage(msg, rules.globalMailbox)
p.transferMessage(rules, progress, msg)
}
progress.messageImported(msg.ID, importedID, err)
}
if len(p.importMsgReqMap) > 0 {
p.importMessages(progress)
}
}
@ -91,6 +97,11 @@ func (p *PMAPIProvider) isMessageDraft(msg Message) bool {
return false
}
func (p *PMAPIProvider) transferDraft(rules transferRules, progress *Progress, msg Message) {
importedID, err := p.importDraft(msg, rules.globalMailbox)
progress.messageImported(msg.ID, importedID, err)
}
func (p *PMAPIProvider) importDraft(msg Message, globalMailbox *Mailbox) (string, error) {
message, attachmentReaders, err := p.parseMessage(msg)
if err != nil {
@ -138,15 +149,30 @@ func (p *PMAPIProvider) importDraft(msg Message, globalMailbox *Mailbox) (string
return draft.ID, nil
}
func (p *PMAPIProvider) importMessage(msg Message, globalMailbox *Mailbox) (string, error) {
func (p *PMAPIProvider) transferMessage(rules transferRules, progress *Progress, msg Message) {
importMsgReq, err := p.generateImportMsgReq(msg, rules.globalMailbox)
if err != nil {
progress.messageImported(msg.ID, "", err)
return
}
importMsgReqSize := len(importMsgReq.Body)
if p.importMsgReqSize+importMsgReqSize > pmapiImportBatchMaxSize || len(p.importMsgReqMap) == pmapiImportBatchMaxItems {
p.importMessages(progress)
}
p.importMsgReqMap[msg.ID] = importMsgReq
p.importMsgReqSize += importMsgReqSize
}
func (p *PMAPIProvider) generateImportMsgReq(msg Message, globalMailbox *Mailbox) (*pmapi.ImportMsgReq, error) {
message, attachmentReaders, err := p.parseMessage(msg)
if err != nil {
return "", errors.Wrap(err, "failed to parse message")
return nil, errors.Wrap(err, "failed to parse message")
}
body, err := p.encryptMessage(message, attachmentReaders)
if err != nil {
return "", errors.Wrap(err, "failed to encrypt message")
return nil, errors.Wrap(err, "failed to encrypt message")
}
unread := 0
@ -165,26 +191,14 @@ func (p *PMAPIProvider) importMessage(msg Message, globalMailbox *Mailbox) (stri
labelIDs = append(labelIDs, globalMailbox.ID)
}
importMsgReq := &pmapi.ImportMsgReq{
return &pmapi.ImportMsgReq{
AddressID: p.addressID,
Body: body,
Unread: unread,
Time: message.Time,
Flags: computeMessageFlags(labelIDs),
LabelIDs: labelIDs,
}
results, err := p.importRequest([]*pmapi.ImportMsgReq{importMsgReq})
if err != nil {
return "", errors.Wrap(err, "failed to import messages")
}
if len(results) == 0 {
return "", errors.New("import ended with no result")
}
if results[0].Error != nil {
return "", errors.Wrap(results[0].Error, "failed to import message")
}
return results[0].MessageID, nil
}, nil
}
func (p *PMAPIProvider) parseMessage(msg Message) (*pmapi.Message, []io.Reader, error) {
@ -218,3 +232,65 @@ func computeMessageFlags(labels []string) (flag int64) {
return flag
}
func (p *PMAPIProvider) importMessages(progress *Progress) {
if progress.shouldStop() {
return
}
importMsgIDs := []string{}
importMsgRequests := []*pmapi.ImportMsgReq{}
for msgID, req := range p.importMsgReqMap {
importMsgIDs = append(importMsgIDs, msgID)
importMsgRequests = append(importMsgRequests, req)
}
log.WithField("msgIDs", importMsgIDs).WithField("size", p.importMsgReqSize).Debug("Importing messages")
results, err := p.importRequest(importMsgRequests)
// In case the whole request failed, try to import every message one by one.
if err != nil || len(results) == 0 {
log.WithError(err).Warning("Importing messages failed, trying one by one")
for msgID, req := range p.importMsgReqMap {
importedID, err := p.importMessage(progress, req)
progress.messageImported(msgID, importedID, err)
}
return
}
// In case request passed but some messages failed, try to import the failed ones alone.
for index, result := range results {
msgID := importMsgIDs[index]
if result.Error != nil {
log.WithError(result.Error).WithField("msg", msgID).Warning("Importing message failed, trying alone")
req := importMsgRequests[index]
importedID, err := p.importMessage(progress, req)
progress.messageImported(msgID, importedID, err)
} else {
progress.messageImported(msgID, result.MessageID, nil)
}
}
p.importMsgReqMap = map[string]*pmapi.ImportMsgReq{}
p.importMsgReqSize = 0
}
func (p *PMAPIProvider) importMessage(progress *Progress, req *pmapi.ImportMsgReq) (importedID string, importedErr error) {
progress.callWrap(func() error {
results, err := p.importRequest([]*pmapi.ImportMsgReq{req})
if err != nil {
return errors.Wrap(err, "failed to import messages")
}
if len(results) == 0 {
importedErr = errors.New("import ended with no result")
return nil // This should not happen, only when there is bug which means we should skip this one.
}
if results[0].Error != nil {
importedErr = errors.Wrap(results[0].Error, "failed to import message")
return nil // Call passed but API refused this message, skip this one.
}
importedID = results[0].MessageID
return nil
})
return
}

View File

@ -178,17 +178,16 @@ func setupPMAPIClientExpectationForExport(m *mocks) {
func setupPMAPIClientExpectationForImport(m *mocks) {
m.pmapiClient.EXPECT().KeyRingForAddressID(gomock.Any()).Return(m.keyring, nil).AnyTimes()
m.pmapiClient.EXPECT().Import(gomock.Any()).DoAndReturn(func(requests []*pmapi.ImportMsgReq) ([]*pmapi.ImportMsgRes, error) {
r.Equal(m.t, 1, len(requests))
request := requests[0]
for _, msgID := range []string{"msg1", "msg2"} {
if bytes.Contains(request.Body, []byte(msgID)) {
return []*pmapi.ImportMsgRes{{MessageID: msgID, Error: nil}}, nil
results := []*pmapi.ImportMsgRes{}
for _, request := range requests {
for _, msgID := range []string{"msg1", "msg2"} {
if bytes.Contains(request.Body, []byte(msgID)) {
results = append(results, &pmapi.ImportMsgRes{MessageID: msgID, Error: nil})
}
}
}
r.Fail(m.t, "No message found")
return nil, nil
}).Times(2)
return results, nil
}).AnyTimes()
}
func setupPMAPIClientExpectationForImportDraft(m *mocks) {

View File

@ -39,7 +39,7 @@ func (p *PMAPIProvider) ensureConnection(callback func() error) error {
return nil
}
log.WithField("attempt", i).WithError(callErr).Warning("Call failed, trying reconnect")
log.WithField("attempt", i).WithError(callErr).Warning("API call failed, trying reconnect")
err := p.tryReconnect()
if err != nil {
return err
@ -57,6 +57,7 @@ func (p *PMAPIProvider) tryReconnect() error {
}
err := p.clientManager.CheckConnection()
log.WithError(err).Debug("Connection check")
if err != nil {
time.Sleep(pmapiReconnectSleep)
previousErr = err

View File

@ -18,11 +18,10 @@
package transfer
import (
"bytes"
"io/ioutil"
"testing"
"github.com/ProtonMail/gopenpgp/crypto"
"github.com/ProtonMail/gopenpgp/v2/crypto"
transfermocks "github.com/ProtonMail/proton-bridge/internal/transfer/mocks"
pmapimocks "github.com/ProtonMail/proton-bridge/pkg/pmapi/mocks"
gomock "github.com/golang/mock/gomock"
@ -62,11 +61,12 @@ func newTestKeyring() *crypto.KeyRing {
if err != nil {
panic(err)
}
userKey, err := crypto.ReadArmoredKeyRing(bytes.NewReader(data))
key, err := crypto.NewKeyFromArmored(string(data))
if err != nil {
panic(err)
}
if err := userKey.Unlock([]byte("testpassphrase")); err != nil {
userKey, err := crypto.NewKeyRing(key)
if err != nil {
panic(err)
}
return userKey

View File

@ -29,9 +29,6 @@ var (
// BuildTime stamp of the build.
BuildTime = ""
// AppShortName to make setup.
AppShortName = "bridge"
// DSNSentry client keys to be able to report crashes to Sentry.
DSNSentry = ""

View File

@ -27,7 +27,7 @@ import (
"mime/quotedprintable"
"net/textproto"
"github.com/ProtonMail/gopenpgp/crypto"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
"github.com/emersion/go-textwrapper"
openpgperrors "golang.org/x/crypto/openpgp/errors"

View File

@ -317,9 +317,8 @@ func (cm *ClientManager) CheckConnection() error {
retStatus := make(chan error)
retAPI := make(chan error)
// Check protonstatus.com without SSL for performance reasons. vpn_status endpoint is fast and
// returns only OK; this endpoint is not known by the public. We check the connection only.
go checkConnection(client, "http://protonstatus.com/vpn_status", retStatus)
// vpn_status endpoint is fast and returns only OK. We check the connection only.
go checkConnection(client, "https://protonstatus.com/vpn_status", retStatus)
// Check of API reachability also uses a fast endpoint.
go checkConnection(client, cm.GetRootURL()+"/tests/ping", retAPI)
@ -351,7 +350,7 @@ func (cm *ClientManager) CheckConnection() error {
func CheckConnection() error {
client := &http.Client{Timeout: time.Second * 10}
retStatus := make(chan error)
checkConnection(client, "http://protonstatus.com/vpn_status", retStatus)
go checkConnection(client, "https://protonstatus.com/vpn_status", retStatus)
return <-retStatus
}

View File

@ -27,6 +27,9 @@ import (
"runtime"
"strings"
"github.com/ProtonMail/proton-bridge/internal/bridge"
"github.com/ProtonMail/proton-bridge/internal/importexport"
"github.com/ProtonMail/proton-bridge/pkg/constants"
"github.com/kardianos/osext"
"github.com/sirupsen/logrus"
)
@ -57,7 +60,6 @@ var (
)
type Updates struct {
appName string
version string
revision string
buildTime string
@ -68,26 +70,44 @@ type Updates struct {
installerFileBaseName string // File for initial install or manual reinstall. per goos [exe, dmg, sh].
versionFileBaseName string // Text file containing information about current file. per goos [_linux,_darwin,_windows].json (have .sig file).
updateFileBaseName string // File for automatic update. per goos [_linux,_darwin,_windows].tgz (have .sig file).
macAppBundleName string // For update procedure.
linuxFileBaseName string // Prefix of linux package names.
macAppBundleName string // Name of Mac app file in the bundle for update procedure.
cachedNewerVersion *VersionInfo // To have info about latest version even when the internet connection drops.
}
// New inits Updates struct.
// `appName` should be in camelCase format for file names. For installer files is converted to CamelCase.
func New(appName, version, revision, buildTime, releaseNotes, releaseFixedBugs, updateTempDir string) *Updates {
// NewBridge inits Updates struct for bridge.
func NewBridge(updateTempDir string) *Updates {
return &Updates{
appName: appName,
version: version,
revision: revision,
buildTime: buildTime,
releaseNotes: releaseNotes,
releaseFixedBugs: releaseFixedBugs,
version: constants.Version,
revision: constants.Revision,
buildTime: constants.BuildTime,
releaseNotes: bridge.ReleaseNotes,
releaseFixedBugs: bridge.ReleaseFixedBugs,
updateTempDir: updateTempDir,
landingPagePath: appName + "/download",
installerFileBaseName: strings.Title(appName) + "-Installer",
landingPagePath: "bridge/download",
installerFileBaseName: "Bridge-Installer",
versionFileBaseName: "current_version",
updateFileBaseName: appName + "_upgrade",
macAppBundleName: "ProtonMail " + strings.Title(appName) + ".app", // For update procedure.
updateFileBaseName: "bridge_upgrade",
linuxFileBaseName: "protonmail-bridge",
macAppBundleName: "ProtonMail Bridge.app",
}
}
// NewImportExport inits Updates struct for import/export.
func NewImportExport(updateTempDir string) *Updates {
return &Updates{
version: constants.Version,
revision: constants.Revision,
buildTime: constants.BuildTime,
releaseNotes: importexport.ReleaseNotes,
releaseFixedBugs: importexport.ReleaseFixedBugs,
updateTempDir: updateTempDir,
landingPagePath: "blog/import-export-beta/",
installerFileBaseName: "Import-Export-Installer",
versionFileBaseName: "current_version_ie",
updateFileBaseName: "ie_upgrade",
linuxFileBaseName: "protonmail-import-export",
macAppBundleName: "ProtonMail Import-Export.app",
}
}
@ -165,7 +185,7 @@ func (u *Updates) getLocalVersion(goos string) VersionInfo {
}
if goos == "linux" {
pkgName := "protonmail-" + u.appName
pkgName := u.linuxFileBaseName
pkgRel := "1"
pkgBase := strings.Join([]string{Host, DownloadPath, pkgName}, "/")

View File

@ -174,5 +174,11 @@ func TestStartUpgrade(t *testing.T) {
}
func newTestUpdates(version string) *Updates {
return New("bridge", version, "rev123", "42", "• new feature", "• fixed foo", testUpdateDir)
u := NewBridge(testUpdateDir)
u.version = version
u.revision = "rev123"
u.buildTime = "42"
u.releaseNotes = "• new feature"
u.releaseFixedBugs = "• fixed foo"
return u
}

View File

@ -1,25 +1,38 @@
.PHONY: check-has-go install-godog test test-live test-debug test-live-debug
.PHONY: check-go check-godog install-godog test test-bridge test-ie test-live test-live-bridge test-live-ie test-stage test-debug test-live-debug bench
export GO111MODULE=on
export BRIDGE_VERSION:=1.3.0-integrationtests
export VERBOSITY?=fatal
export TEST_DATA=testdata
export TEST_APP?=bridge
check-has-go:
# Tests do not run in parallel. This will overrule user settings.
MAKEFLAGS=-j1
check-go:
@which go || (echo "Install Go-lang!" && exit 1)
install-godog: check-has-go
check-godog:
@which godog || $(MAKE) install-godog
install-godog: check-go
go get github.com/cucumber/godog/cmd/godog@v0.8.1
test:
which godog || $(MAKE) install-godog
TEST_ENV=fake TEST_ACCOUNTS=accounts/fake.json godog --tags="~@ignore" $(FEATURES)
test: test-bridge test-ie
test-bridge: FEATURES ?= features/bridge
test-bridge: check-godog
TEST_APP=bridge TEST_ENV=fake TEST_ACCOUNTS=accounts/fake.json godog --tags="~@ignore" $(FEATURES)
test-ie: FEATURES ?= features/ie
test-ie: check-godog
TEST_APP=ie TEST_ENV=fake TEST_ACCOUNTS=accounts/fake.json godog --tags="~@ignore" $(FEATURES)
# Doesn't work in parallel!
# Provide TEST_ACCOUNTS with your accounts.
test-live:
which godog || $(MAKE) install-godog
TEST_ENV=live godog --tags="~@ignore && ~@ignore-live" $(FEATURES)
test-live: test-live-bridge test-live-ie
test-live-bridge: FEATURES ?= features/bridge
test-live-bridge: check-godog
TEST_APP=bridge TEST_ENV=live godog --tags="~@ignore && ~@ignore-live" $(FEATURES)
test-live-ie: FEATURES ?= features/ie
test-live-ie: check-godog
TEST_APP=ie TEST_ENV=live godog --tags="~@ignore && ~@ignore-live" $(FEATURES)
# Doesn't work in parallel!
# Provide TEST_ACCOUNTS with your accounts.

45
test/api_actions_test.go Normal file
View File

@ -0,0 +1,45 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// ProtonMail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package tests
import (
"time"
"github.com/cucumber/godog"
)
func APIActionsFeatureContext(s *godog.Suite) {
s.Step(`^the internet connection is lost$`, theInternetConnectionIsLost)
s.Step(`^the internet connection is restored$`, theInternetConnectionIsRestored)
s.Step(`^(\d+) seconds pass$`, secondsPass)
}
func theInternetConnectionIsLost() error {
ctx.GetPMAPIController().TurnInternetConnectionOff()
return nil
}
func theInternetConnectionIsRestored() error {
ctx.GetPMAPIController().TurnInternetConnectionOn()
return nil
}
func secondsPass(seconds int) error {
time.Sleep(time.Duration(seconds) * time.Second)
return nil
}

View File

@ -29,6 +29,8 @@ import (
func APIChecksFeatureContext(s *godog.Suite) {
s.Step(`^API endpoint "([^"]*)" is called with:$`, apiIsCalledWith)
s.Step(`^message is sent with API call:$`, messageIsSentWithAPICall)
s.Step(`^API mailbox "([^"]*)" for "([^"]*)" has messages$`, apiMailboxForUserHasMessages)
s.Step(`^API mailbox "([^"]*)" for address "([^"]*)" of "([^"]*)" has messages$`, apiMailboxForAddressOfUserHasMessages)
}
func apiIsCalledWith(endpoint string, data *gherkin.DocString) error {
@ -77,3 +79,41 @@ func checkAllRequiredFieldsForSendingMessage(request []byte) bool {
}
return true
}
func apiMailboxForUserHasMessages(mailboxName, bddUserID string, messages *gherkin.DataTable) error {
return apiMailboxForAddressOfUserHasMessages(mailboxName, "", bddUserID, messages)
}
func apiMailboxForAddressOfUserHasMessages(mailboxName, bddAddressID, bddUserID string, messages *gherkin.DataTable) error {
account := ctx.GetTestAccountWithAddress(bddUserID, bddAddressID)
if account == nil {
return godog.ErrPending
}
labelIDs, err := ctx.GetPMAPIController().GetLabelIDs(account.Username(), []string{mailboxName})
if err != nil {
return internalError(err, "getting label %s for %s", mailboxName, account.Username())
}
labelID := labelIDs[0]
pmapiMessages, err := ctx.GetPMAPIController().GetMessages(account.Username(), labelID)
if err != nil {
return err
}
head := messages.Rows[0].Cells
for _, row := range messages.Rows[1:] {
found, err := messagesContainsMessageRow(account, pmapiMessages, head, row)
if err != nil {
return err
}
if !found {
rowMap := map[string]string{}
for idx, cell := range row.Cells {
rowMap[head[idx].Value] = cell.Value
}
return fmt.Errorf("message %v not found", rowMap)
}
}
return nil
}

31
test/api_setup_test.go Normal file
View File

@ -0,0 +1,31 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// ProtonMail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package tests
import (
"github.com/cucumber/godog"
)
func APISetupFeatureContext(s *godog.Suite) {
s.Step(`^there is no internet connection$`, thereIsNoInternetConnection)
}
func thereIsNoInternetConnection() error {
ctx.GetPMAPIController().TurnInternetConnectionOff()
return nil
}

View File

@ -18,19 +18,27 @@
package tests
import (
"os"
"github.com/ProtonMail/proton-bridge/test/context"
"github.com/cucumber/godog"
)
const (
timeFormat = "2006-01-02T15:04:05"
)
func FeatureContext(s *godog.Suite) {
s.BeforeScenario(beforeScenario)
s.AfterScenario(afterScenario)
APIActionsFeatureContext(s)
APIChecksFeatureContext(s)
APISetupFeatureContext(s)
BridgeActionsFeatureContext(s)
BridgeChecksFeatureContext(s)
BridgeSetupFeatureContext(s)
CommonChecksFeatureContext(s)
IMAPActionsAuthFeatureContext(s)
IMAPActionsMailboxFeatureContext(s)
@ -45,18 +53,32 @@ func FeatureContext(s *godog.Suite) {
StoreActionsFeatureContext(s)
StoreChecksFeatureContext(s)
StoreSetupFeatureContext(s)
TransferActionsFeatureContext(s)
TransferChecksFeatureContext(s)
TransferSetupFeatureContext(s)
UsersActionsFeatureContext(s)
UsersSetupFeatureContext(s)
UsersChecksFeatureContext(s)
}
var ctx *context.TestContext //nolint[gochecknoglobals]
func beforeScenario(scenario interface{}) {
ctx = context.New()
// bridge or ie. With godog 0.10.x and later it can be determined from
// scenario.Uri and its file location.
app := os.Getenv("TEST_APP")
ctx = context.New(app)
}
func afterScenario(scenario interface{}, err error) {
if err != nil {
for _, user := range ctx.GetBridge().GetUsers() {
user.GetStore().TestDumpDB(ctx.GetTestingT())
for _, user := range ctx.GetUsers().GetUsers() {
store := user.GetStore()
if store != nil {
store.TestDumpDB(ctx.GetTestingT())
}
}
}
ctx.Cleanup()

View File

@ -27,7 +27,7 @@ import (
)
func benchTestContext() (*context.TestContext, *mocks.IMAPClient) {
ctx := context.New()
ctx := context.New("bridge")
username := "user"
account := ctx.GetTestAccount(username)

View File

@ -18,28 +18,16 @@
package tests
import (
"time"
"github.com/cucumber/godog"
)
func BridgeActionsFeatureContext(s *godog.Suite) {
s.Step(`^bridge starts$`, bridgeStarts)
s.Step(`^bridge syncs "([^"]*)"$`, bridgeSyncsUser)
s.Step(`^"([^"]*)" logs in to bridge$`, userLogsInToBridge)
s.Step(`^"([^"]*)" logs in to bridge with bad password$`, userLogsInToBridgeWithBadPassword)
s.Step(`^"([^"]*)" logs out from bridge$`, userLogsOutFromBridge)
s.Step(`^"([^"]*)" changes the address mode$`, userChangesTheAddressMode)
s.Step(`^user deletes "([^"]*)" from bridge$`, userDeletesUserFromBridge)
s.Step(`^user deletes "([^"]*)" from bridge with cache$`, userDeletesUserFromBridgeWithCache)
s.Step(`^the internet connection is lost$`, theInternetConnectionIsLost)
s.Step(`^the internet connection is restored$`, theInternetConnectionIsRestored)
s.Step(`^(\d+) seconds pass$`, secondsPass)
s.Step(`^"([^"]*)" swaps address "([^"]*)" with address "([^"]*)"$`, swapsAddressWithAddress)
}
func bridgeStarts() error {
ctx.SetLastBridgeError(ctx.RestartBridge())
ctx.SetLastError(ctx.RestartBridge())
return nil
}
@ -51,113 +39,6 @@ func bridgeSyncsUser(bddUserID string) error {
if err := ctx.WaitForSync(account.Username()); err != nil {
return internalError(err, "waiting for sync")
}
ctx.SetLastBridgeError(ctx.GetTestingError())
ctx.SetLastError(ctx.GetTestingError())
return nil
}
func userLogsInToBridge(bddUserID string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
ctx.SetLastBridgeError(ctx.LoginUser(account.Username(), account.Password(), account.MailboxPassword()))
return nil
}
func userLogsInToBridgeWithBadPassword(bddUserID string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
ctx.SetLastBridgeError(ctx.LoginUser(account.Username(), "you shall not pass!", "123"))
return nil
}
func userLogsOutFromBridge(bddUserID string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
ctx.SetLastBridgeError(ctx.LogoutUser(account.Username()))
return nil
}
func userChangesTheAddressMode(bddUserID string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
bridgeUser, err := ctx.GetUser(account.Username())
if err != nil {
return internalError(err, "getting user %s", account.Username())
}
if err := bridgeUser.SwitchAddressMode(); err != nil {
return err
}
ctx.EventuallySyncIsFinishedForUsername(account.Username())
return nil
}
func userDeletesUserFromBridge(bddUserID string) error {
return deleteUserFromBridge(bddUserID, false)
}
func userDeletesUserFromBridgeWithCache(bddUserID string) error {
return deleteUserFromBridge(bddUserID, true)
}
func deleteUserFromBridge(bddUserID string, cache bool) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
bridgeUser, err := ctx.GetUser(account.Username())
if err != nil {
return internalError(err, "getting user %s", account.Username())
}
return ctx.GetBridge().DeleteUser(bridgeUser.ID(), cache)
}
func theInternetConnectionIsLost() error {
ctx.GetPMAPIController().TurnInternetConnectionOff()
return nil
}
func theInternetConnectionIsRestored() error {
ctx.GetPMAPIController().TurnInternetConnectionOn()
return nil
}
func secondsPass(seconds int) error {
time.Sleep(time.Duration(seconds) * time.Second)
return nil
}
func swapsAddressWithAddress(bddUserID, bddAddressID1, bddAddressID2 string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
address1ID := account.GetAddressID(bddAddressID1)
address2ID := account.GetAddressID(bddAddressID2)
addressIDs := make([]string, len(*account.Addresses()))
var address1Index, address2Index int
for i, v := range *account.Addresses() {
if v.ID == address1ID {
address1Index = i
}
if v.ID == address2ID {
address2Index = i
}
addressIDs[i] = v.ID
}
addressIDs[address1Index], addressIDs[address2Index] = addressIDs[address2Index], addressIDs[address1Index]
ctx.ReorderAddresses(account.Username(), bddAddressID1, bddAddressID2)
return ctx.GetPMAPIController().ReorderAddresses(account.User(), addressIDs)
}

View File

@ -0,0 +1,37 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// ProtonMail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package tests
import (
"github.com/cucumber/godog"
a "github.com/stretchr/testify/assert"
)
func CommonChecksFeatureContext(s *godog.Suite) {
s.Step(`^last response is "([^"]*)"$`, lastResponseIs)
}
func lastResponseIs(expectedResponse string) error {
err := ctx.GetLastError()
if expectedResponse == "OK" {
a.NoError(ctx.GetTestingT(), err)
} else {
a.EqualError(ctx.GetTestingT(), err, expectedResponse)
}
return ctx.GetTestingError()
}

View File

@ -32,9 +32,10 @@ func (ctx *TestContext) GetBridge() *bridge.Bridge {
}
// withBridgeInstance creates a bridge instance for use in the test.
// Every TestContext has this by default and thus this doesn't need to be exported.
// TestContext has this by default once called with env variable TEST_APP=bridge.
func (ctx *TestContext) withBridgeInstance() {
ctx.bridge = newBridgeInstance(ctx.t, ctx.cfg, ctx.credStore, ctx.listener, ctx.clientManager)
ctx.users = ctx.bridge.Users
ctx.addCleanupChecked(ctx.bridge.ClearData, "Cleaning bridge data")
}
@ -69,13 +70,3 @@ func newBridgeInstance(
pref := preferences.New(cfg)
return bridge.New(cfg, pref, panicHandler, eventListener, clientManager, credStore)
}
// SetLastBridgeError sets the last error that occurred while executing a bridge action.
func (ctx *TestContext) SetLastBridgeError(err error) {
ctx.bridgeLastError = err
}
// GetLastBridgeError returns the last error that occurred while executing a bridge action.
func (ctx *TestContext) GetLastBridgeError() error {
return ctx.bridgeLastError
}

View File

@ -77,6 +77,9 @@ func (c *fakeConfig) GetLogPrefix() string {
func (c *fakeConfig) GetPreferencesPath() string {
return filepath.Join(c.dir, "prefs.json")
}
func (c *fakeConfig) GetTransferDir() string {
return c.dir
}
func (c *fakeConfig) GetTLSCertPath() string {
return filepath.Join(c.dir, "cert.pem")
}

View File

@ -20,6 +20,8 @@ package context
import (
"github.com/ProtonMail/proton-bridge/internal/bridge"
"github.com/ProtonMail/proton-bridge/internal/importexport"
"github.com/ProtonMail/proton-bridge/internal/transfer"
"github.com/ProtonMail/proton-bridge/internal/users"
"github.com/ProtonMail/proton-bridge/pkg/listener"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
@ -46,10 +48,12 @@ type TestContext struct {
pmapiController PMAPIController
clientManager *pmapi.ClientManager
// Bridge core related variables.
bridge *bridge.Bridge
bridgeLastError error
credStore users.CredentialsStorer
// Core related variables.
bridge *bridge.Bridge
importExport *importexport.ImportExport
users *users.Users
credStore users.CredentialsStorer
lastError error
// IMAP related variables.
imapAddr string
@ -63,6 +67,12 @@ type TestContext struct {
smtpClients map[string]*mocks.SMTPClient
smtpLastResponses map[string]*mocks.SMTPResponse
// Transfer related variables.
transferLocalRootForImport string
transferLocalRootForExport string
transferRemoteIMAPServer *mocks.IMAPServer
transferProgress *transfer.Progress
// These are the cleanup steps executed when Cleanup() is called.
cleanupSteps []*Cleaner
@ -71,7 +81,7 @@ type TestContext struct {
}
// New returns a new test TestContext.
func New() *TestContext {
func New(app string) *TestContext {
setLogrusVerbosityFromEnv()
cfg := newFakeConfig()
@ -96,8 +106,15 @@ func New() *TestContext {
// Ensure that the config is cleaned up after the test is over.
ctx.addCleanupChecked(cfg.ClearData, "Cleaning bridge config data")
// Create bridge instance under test.
ctx.withBridgeInstance()
// Create bridge or import/export instance under test.
switch app {
case "bridge":
ctx.withBridgeInstance()
case "ie":
ctx.withImportExportInstance()
default:
panic("unknown app: " + app)
}
return ctx
}
@ -125,3 +142,13 @@ func (ctx *TestContext) GetTestingT() *bddT { //nolint[golint]
func (ctx *TestContext) GetTestingError() error {
return ctx.t.getErrors()
}
// SetLastError sets the last error that occurred while executing an action.
func (ctx *TestContext) SetLastError(err error) {
ctx.lastError = err
}
// GetLastError returns the last error that occurred while executing an action.
func (ctx *TestContext) GetLastError() error {
return ctx.lastError
}

View File

@ -0,0 +1,48 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// ProtonMail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package context
import (
"github.com/ProtonMail/proton-bridge/internal/importexport"
"github.com/ProtonMail/proton-bridge/internal/users"
"github.com/ProtonMail/proton-bridge/pkg/listener"
)
// GetImportExport returns import/export instance.
func (ctx *TestContext) GetImportExport() *importexport.ImportExport {
return ctx.importExport
}
// withImportExportInstance creates a import/export instance for use in the test.
// TestContext has this by default once called with env variable TEST_APP=ie.
func (ctx *TestContext) withImportExportInstance() {
ctx.importExport = newImportExportInstance(ctx.t, ctx.cfg, ctx.credStore, ctx.listener, ctx.clientManager)
ctx.users = ctx.importExport.Users
}
// newImportExportInstance creates a new import/export instance configured to use the given config/credstore.
func newImportExportInstance(
t *bddT,
cfg importexport.Configer,
credStore users.CredentialsStorer,
eventListener listener.Listener,
clientManager users.ClientManager,
) *importexport.ImportExport {
panicHandler := &panicHandler{t: t}
return importexport.New(cfg, panicHandler, eventListener, clientManager, credStore)
}

View File

@ -33,6 +33,7 @@ type PMAPIController interface {
GetLabelIDs(username string, labelNames []string) ([]string, error)
AddUserMessage(username string, message *pmapi.Message) error
GetMessageID(username, messageIndex string) string
GetMessages(username, labelID string) ([]*pmapi.Message, error)
ReorderAddresses(user *pmapi.User, addressIDs []string) error
PrintCalls()
WasCalled(method, path string, expectedRequest []byte) bool

91
test/context/transfer.go Normal file
View File

@ -0,0 +1,91 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// ProtonMail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package context
import (
"io/ioutil"
"math/rand"
"os"
"strconv"
"github.com/ProtonMail/proton-bridge/internal/transfer"
"github.com/ProtonMail/proton-bridge/test/mocks"
)
// SetTransferProgress sets transfer progress.
func (ctx *TestContext) SetTransferProgress(progress *transfer.Progress) {
ctx.transferProgress = progress
}
// GetTransferProgress returns transfer progress.
func (ctx *TestContext) GetTransferProgress() *transfer.Progress {
return ctx.transferProgress
}
// GetTransferLocalRootForImport creates temporary root for importing
// if it not exists yet, and returns its path.
func (ctx *TestContext) GetTransferLocalRootForImport() string {
if ctx.transferLocalRootForImport != "" {
return ctx.transferLocalRootForImport
}
root := ctx.createLocalRoot()
ctx.transferLocalRootForImport = root
return root
}
// GetTransferLocalRootForExport creates temporary root for exporting
// if it not exists yet, and returns its path.
func (ctx *TestContext) GetTransferLocalRootForExport() string {
if ctx.transferLocalRootForExport != "" {
return ctx.transferLocalRootForExport
}
root := ctx.createLocalRoot()
ctx.transferLocalRootForExport = root
return root
}
func (ctx *TestContext) createLocalRoot() string {
root, err := ioutil.TempDir("", "transfer")
if err != nil {
panic("failed to create temp transfer root: " + err.Error())
}
ctx.addCleanupChecked(func() error {
return os.RemoveAll(root)
}, "Cleaning transfer data")
return root
}
// GetTransferRemoteIMAPServer creates mocked IMAP server if it not created yet, and returns it.
func (ctx *TestContext) GetTransferRemoteIMAPServer() *mocks.IMAPServer {
if ctx.transferRemoteIMAPServer != nil {
return ctx.transferRemoteIMAPServer
}
port := 21300 + rand.Intn(100)
ctx.transferRemoteIMAPServer = mocks.NewIMAPServer("user", "pass", "127.0.0.1", strconv.Itoa(port))
ctx.transferRemoteIMAPServer.Start()
ctx.addCleanupChecked(func() error {
ctx.transferRemoteIMAPServer.Stop()
return nil
}, "Cleaning transfer IMAP server")
return ctx.transferRemoteIMAPServer
}

View File

@ -30,11 +30,16 @@ import (
"github.com/stretchr/testify/assert"
)
// GetUsers returns users instance.
func (ctx *TestContext) GetUsers() *users.Users {
return ctx.users
}
// LoginUser logs in the user with the given username, password, and mailbox password.
func (ctx *TestContext) LoginUser(username, password, mailboxPassword string) (err error) {
srp.RandReader = rand.New(rand.NewSource(42))
client, auth, err := ctx.bridge.Login(username, password)
client, auth, err := ctx.users.Login(username, password)
if err != nil {
return errors.Wrap(err, "failed to login")
}
@ -45,7 +50,7 @@ func (ctx *TestContext) LoginUser(username, password, mailboxPassword string) (e
}
}
user, err := ctx.bridge.FinishLogin(client, auth, mailboxPassword)
user, err := ctx.users.FinishLogin(client, auth, mailboxPassword)
if err != nil {
return errors.Wrap(err, "failed to finish login")
}
@ -57,7 +62,7 @@ func (ctx *TestContext) LoginUser(username, password, mailboxPassword string) (e
// GetUser retrieves the bridge user matching the given query string.
func (ctx *TestContext) GetUser(username string) (*users.User, error) {
return ctx.bridge.GetUser(username)
return ctx.users.GetUser(username)
}
// GetStore retrieves the store for given username.
@ -100,6 +105,9 @@ func (ctx *TestContext) WaitForSync(username string) error {
if err != nil {
return err
}
if store == nil {
return nil
}
// First wait for ongoing sync to be done before starting and waiting for new one.
ctx.eventuallySyncIsFinished(store)
store.TestSync()
@ -121,7 +129,7 @@ func (ctx *TestContext) EventuallySyncIsFinishedForUsername(username string) {
// LogoutUser logs out the given user.
func (ctx *TestContext) LogoutUser(query string) (err error) {
user, err := ctx.bridge.GetUser(query)
user, err := ctx.users.GetUser(query)
if err != nil {
return errors.Wrap(err, "failed to get user")
}
@ -135,12 +143,12 @@ func (ctx *TestContext) LogoutUser(query string) (err error) {
// DeleteUser deletes the given user.
func (ctx *TestContext) DeleteUser(query string, deleteStore bool) (err error) {
user, err := ctx.bridge.GetUser(query)
user, err := ctx.users.GetUser(query)
if err != nil {
return errors.Wrap(err, "failed to get user")
}
if err = ctx.bridge.DeleteUser(user.ID(), deleteStore); err != nil {
if err = ctx.users.DeleteUser(user.ID(), deleteStore); err != nil {
err = errors.Wrap(err, "failed to delete user")
}

View File

@ -159,3 +159,17 @@ func (ctl *Controller) resetUsers() {
func (ctl *Controller) GetMessageID(username, messageIndex string) string {
return messageIndex
}
func (ctl *Controller) GetMessages(username, labelID string) ([]*pmapi.Message, error) {
messages := []*pmapi.Message{}
for _, fakeAPI := range ctl.fakeAPIs {
if fakeAPI.username == username {
for _, message := range fakeAPI.messages {
if labelID == "" || message.HasLabelID(labelID) {
messages = append(messages, message)
}
}
}
}
return messages, nil
}

View File

@ -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-----

View File

@ -20,6 +20,7 @@ package fakeapi
import (
"bytes"
"fmt"
"io/ioutil"
"net/mail"
"time"
@ -67,7 +68,7 @@ func (api *FakePMAPI) ListMessages(filter *pmapi.MessagesFilter) ([]*pmapi.Messa
for idx := 0; idx < len(api.messages); idx++ {
var message *pmapi.Message
if !*filter.Desc {
if filter.Desc == nil || !*filter.Desc {
message = api.messages[idx]
if filter.BeginID == "" || message.ID == filter.BeginID {
skipByIDBegin = false
@ -81,7 +82,7 @@ func (api *FakePMAPI) ListMessages(filter *pmapi.MessagesFilter) ([]*pmapi.Messa
if skipByIDBegin || skipByIDEnd {
continue
}
if !*filter.Desc {
if filter.Desc == nil || !*filter.Desc {
if message.ID == filter.EndID {
skipByIDEnd = true
}
@ -189,36 +190,60 @@ func (api *FakePMAPI) SendMessage(messageID string, sendMessageRequest *pmapi.Se
}
func (api *FakePMAPI) Import(importMessageRequests []*pmapi.ImportMsgReq) ([]*pmapi.ImportMsgRes, error) {
if err := api.checkAndRecordCall(POST, "/import", importMessageRequests); err != nil {
return nil, err
}
msgRes := []*pmapi.ImportMsgRes{}
for _, msgReq := range importMessageRequests {
mailMessage, err := mail.ReadMessage(bytes.NewBuffer(msgReq.Body))
message, err := api.generateMessageFromImportRequest(msgReq)
if err != nil {
msgRes = append(msgRes, &pmapi.ImportMsgRes{
Error: err,
})
}
messageID := api.controller.messageIDGenerator.next("")
message := &pmapi.Message{
ID: messageID,
AddressID: msgReq.AddressID,
Sender: &mail.Address{Address: mailMessage.Header.Get("From")},
ToList: []*mail.Address{{Address: mailMessage.Header.Get("To")}},
Subject: mailMessage.Header.Get("Subject"),
Unread: msgReq.Unread,
LabelIDs: msgReq.LabelIDs,
Body: string(msgReq.Body),
Flags: msgReq.Flags,
Time: msgReq.Time,
continue
}
msgRes = append(msgRes, &pmapi.ImportMsgRes{
Error: nil,
MessageID: messageID,
MessageID: message.ID,
})
api.addMessage(message)
}
return msgRes, nil
}
func (api *FakePMAPI) generateMessageFromImportRequest(msgReq *pmapi.ImportMsgReq) (*pmapi.Message, error) {
mailMessage, err := mail.ReadMessage(bytes.NewBuffer(msgReq.Body))
if err != nil {
return nil, err
}
body, err := ioutil.ReadAll(mailMessage.Body)
if err != nil {
return nil, err
}
sender, err := mail.ParseAddress(mailMessage.Header.Get("From"))
if err != nil {
return nil, err
}
toList, err := mail.ParseAddressList(mailMessage.Header.Get("To"))
if err != nil {
return nil, err
}
messageID := api.controller.messageIDGenerator.next("")
return &pmapi.Message{
ID: messageID,
AddressID: msgReq.AddressID,
Sender: sender,
ToList: toList,
Subject: mailMessage.Header.Get("Subject"),
Unread: msgReq.Unread,
LabelIDs: append(msgReq.LabelIDs, pmapi.AllMailLabel),
Body: string(body),
Header: mailMessage.Header,
Flags: msgReq.Flags,
Time: msgReq.Time,
}, nil
}
func (api *FakePMAPI) addMessage(message *pmapi.Message) {
api.messages = append(api.messages, message)
api.addEventMessage(pmapi.EventCreate, message)

View File

@ -29,16 +29,16 @@ Feature: IMAP auth
Scenario: Authenticates with freshly logged-out user
Given there is connected user "user"
When "user" logs out from bridge
When "user" logs out
And IMAP client authenticates "user"
Then IMAP response is "IMAP error: NO account is logged out, use the app to login again"
Scenario: Authenticates user which was re-logged in
Given there is connected user "user"
When "user" logs out from bridge
When "user" logs out
And IMAP client authenticates "user"
Then IMAP response is "IMAP error: NO account is logged out, use the app to login again"
When "user" logs in to bridge
When "user" logs in
And IMAP client authenticates "user"
Then IMAP response is "OK"
When IMAP client selects "INBOX"

View File

@ -26,7 +26,7 @@ Feature: Address mode
Scenario: Switch address mode from combined to split mode
Given there is "userMoreAddresses" in "combined" address mode
When "userMoreAddresses" changes the address mode
Then bridge response is "OK"
Then last response is "OK"
And "userMoreAddresses" has address mode in "split" mode
And mailbox "Folders/mbox" for address "primary" of "userMoreAddresses" has messages
| from | to | subject |
@ -38,7 +38,7 @@ Feature: Address mode
Scenario: Switch address mode from split to combined mode
Given there is "userMoreAddresses" in "split" address mode
When "userMoreAddresses" changes the address mode
Then bridge response is "OK"
Then last response is "OK"
And "userMoreAddresses" has address mode in "combined" mode
And mailbox "Folders/mbox" for address "primary" of "userMoreAddresses" has messages
| from | to | subject |

View File

@ -1,36 +1,36 @@
Feature: Delete user
Scenario: Deleting connected user
Given there is connected user "user"
When user deletes "user" from bridge
Then bridge response is "OK"
When user deletes "user"
Then last response is "OK"
And "user" has database file
Scenario: Deleting connected user with cache
Given there is connected user "user"
When user deletes "user" from bridge with cache
Then bridge response is "OK"
When user deletes "user" with cache
Then last response is "OK"
And "user" does not have database file
Scenario: Deleting connected user without database file
Given there is connected user "user"
And there is no database file for "user"
When user deletes "user" from bridge with cache
Then bridge response is "OK"
When user deletes "user" with cache
Then last response is "OK"
Scenario: Deleting disconnected user
Given there is disconnected user "user"
When user deletes "user" from bridge
Then bridge response is "OK"
When user deletes "user"
Then last response is "OK"
And "user" has database file
Scenario: Deleting disconnected user with cache
Given there is disconnected user "user"
When user deletes "user" from bridge with cache
Then bridge response is "OK"
When user deletes "user" with cache
Then last response is "OK"
And "user" does not have database file
Scenario: Deleting disconnected user without database file
Given there is disconnected user "user"
And there is no database file for "user"
When user deletes "user" from bridge with cache
Then bridge response is "OK"
When user deletes "user" with cache
Then last response is "OK"

View File

@ -1,47 +1,47 @@
Feature: Login to bridge for the first time
Scenario: Normal bridge login
Feature: Login for the first time
Scenario: Normal login
Given there is user "user"
When "user" logs in to bridge
Then bridge response is "OK"
When "user" logs in
Then last response is "OK"
And "user" is connected
And "user" has database file
And "user" has running event loop
Scenario: Login with bad username
When "user" logs in to bridge with bad password
Then bridge response is "failed to login: Incorrect login credentials. Please try again"
When "user" logs in with bad password
Then last response is "failed to login: Incorrect login credentials. Please try again"
Scenario: Login with bad password
Given there is user "user"
When "user" logs in to bridge with bad password
Then bridge response is "failed to login: Incorrect login credentials. Please try again"
When "user" logs in with bad password
Then last response is "failed to login: Incorrect login credentials. Please try again"
Scenario: Login without internet connection
Given there is no internet connection
When "user" logs in to bridge
Then bridge response is "failed to login: cannot reach the server"
When "user" logs in
Then last response is "failed to login: cannot reach the server"
@ignore-live
Scenario: Login user with 2FA
Given there is user "user2fa"
When "user2fa" logs in to bridge
Then bridge response is "OK"
When "user2fa" logs in
Then last response is "OK"
And "user2fa" is connected
And "user2fa" has database file
And "user2fa" has running event loop
Scenario: Login user with capital letters in address
Given there is user "userAddressWithCapitalLetter"
When "userAddressWithCapitalLetter" logs in to bridge
Then bridge response is "OK"
When "userAddressWithCapitalLetter" logs in
Then last response is "OK"
And "userAddressWithCapitalLetter" is connected
And "userAddressWithCapitalLetter" has database file
And "userAddressWithCapitalLetter" has running event loop
Scenario: Login user with more addresses
Given there is user "userMoreAddresses"
When "userMoreAddresses" logs in to bridge
Then bridge response is "OK"
When "userMoreAddresses" logs in
Then last response is "OK"
And "userMoreAddresses" is connected
And "userMoreAddresses" has database file
And "userMoreAddresses" has running event loop
@ -49,8 +49,8 @@ Feature: Login to bridge for the first time
@ignore-live
Scenario: Login user with disabled primary address
Given there is user "userDisabledPrimaryAddress"
When "userDisabledPrimaryAddress" logs in to bridge
Then bridge response is "OK"
When "userDisabledPrimaryAddress" logs in
Then last response is "OK"
And "userDisabledPrimaryAddress" is connected
And "userDisabledPrimaryAddress" has database file
And "userDisabledPrimaryAddress" has running event loop
@ -58,9 +58,9 @@ Feature: Login to bridge for the first time
Scenario: Login two users
Given there is user "user"
And there is user "userMoreAddresses"
When "user" logs in to bridge
Then bridge response is "OK"
When "user" logs in
Then last response is "OK"
And "user" is connected
When "userMoreAddresses" logs in to bridge
Then bridge response is "OK"
When "userMoreAddresses" logs in
Then last response is "OK"
And "userMoreAddresses" is connected

View File

@ -1,9 +1,9 @@
Feature: Re-login to bridge
Feature: Re-login
Scenario: Re-login with connected user and database file
Given there is connected user "user"
And there is database file for "user"
When "user" logs in to bridge
Then bridge response is "failed to finish login: user is already connected"
When "user" logs in
Then last response is "failed to finish login: user is already connected"
And "user" is connected
And "user" has running event loop
@ -11,8 +11,8 @@ Feature: Re-login to bridge
Scenario: Re-login with connected user and no database file
Given there is connected user "user"
And there is no database file for "user"
When "user" logs in to bridge
Then bridge response is "failed to finish login: user is already connected"
When "user" logs in
Then last response is "failed to finish login: user is already connected"
And "user" is connected
And "user" has database file
And "user" has running event loop
@ -20,16 +20,16 @@ Feature: Re-login to bridge
Scenario: Re-login with disconnected user and database file
Given there is disconnected user "user"
And there is database file for "user"
When "user" logs in to bridge
Then bridge response is "OK"
When "user" logs in
Then last response is "OK"
And "user" is connected
And "user" has running event loop
Scenario: Re-login with disconnected user and no database file
Given there is disconnected user "user"
And there is no database file for "user"
When "user" logs in to bridge
Then bridge response is "OK"
When "user" logs in
Then last response is "OK"
And "user" is connected
And "user" has database file
And "user" has running event loop

View File

@ -28,7 +28,7 @@ Feature: Sync bridge
Scenario: Sync in combined mode
And there is "userMoreAddresses" in "combined" address mode
When bridge syncs "userMoreAddresses"
Then bridge response is "OK"
Then last response is "OK"
And "userMoreAddresses" has the following messages
| mailboxes | messages |
| INBOX | 1101 |
@ -43,7 +43,7 @@ Feature: Sync bridge
Scenario: Sync in split mode
And there is "userMoreAddresses" in "split" address mode
When bridge syncs "userMoreAddresses"
Then bridge response is "OK"
Then last response is "OK"
And "userMoreAddresses" has the following messages
| address | mailboxes | messages |
| primary | INBOX | 1001 |

View File

@ -0,0 +1,43 @@
Feature: Export to EML files
Background:
Given there is connected user "user"
And there is "user" with mailbox "Folders/Foo"
And there are messages in mailbox "INBOX" for "user"
| from | to | subject | time |
| bridgetest@pm.test | test@protonmail.com | hello | 2020-01-01T12:00:00 |
And there are messages in mailbox "Folders/Foo" for "user"
| from | to | subject | time |
| foo@example.com | bridgetest@protonmail.com | one | 2020-01-01T12:00:00 |
| bar@example.com | bridgetest@protonmail.com | two | 2020-01-01T13:00:00 |
| bar@example.com | bridgetest@protonmail.com | three | 2020-01-01T12:30:00 |
Scenario: Export all
When user "user" exports to EML files
Then progress result is "OK"
# Every message is also in All Mail.
And transfer exported 8 messages
And transfer imported 8 messages
And transfer failed for 0 messages
And transfer exported messages
| folder | from | to | subject | time |
| Inbox | bridgetest@pm.test | test@protonmail.com | hello | 2020-01-01T12:00:00 |
| Foo | foo@example.com | bridgetest@protonmail.com | one | 2020-01-01T12:00:00 |
| Foo | bar@example.com | bridgetest@protonmail.com | two | 2020-01-01T13:00:00 |
| Foo | bar@example.com | bridgetest@protonmail.com | three | 2020-01-01T12:30:00 |
| All Mail | bridgetest@pm.test | test@protonmail.com | hello | 2020-01-01T12:00:00 |
| All Mail | foo@example.com | bridgetest@protonmail.com | one | 2020-01-01T12:00:00 |
| All Mail | bar@example.com | bridgetest@protonmail.com | two | 2020-01-01T13:00:00 |
| All Mail | bar@example.com | bridgetest@protonmail.com | three | 2020-01-01T12:30:00 |
Scenario: Export only Foo with time limit
When user "user" exports to EML files with rules
| source | target | from | to |
| Foo | | 2020-01-01T12:10:00 | 2020-01-01T13:00:00 |
Then progress result is "OK"
And transfer exported 2 messages
And transfer imported 2 messages
And transfer failed for 0 messages
And transfer exported messages
| folder | from | to | subject | time |
| Foo | bar@example.com | bridgetest@protonmail.com | two | 2020-01-01T13:00:00 |
| Foo | bar@example.com | bridgetest@protonmail.com | three | 2020-01-01T12:30:00 |

View File

@ -0,0 +1,43 @@
Feature: Export to MBOX files
Background:
Given there is connected user "user"
And there is "user" with mailbox "Folders/Foo"
And there are messages in mailbox "INBOX" for "user"
| from | to | subject | time |
| bridgetest@pm.test | test@protonmail.com | hello | 2020-01-01T12:00:00 |
And there are messages in mailbox "Folders/Foo" for "user"
| from | to | subject | time |
| foo@example.com | bridgetest@protonmail.com | one | 2020-01-01T12:00:00 |
| bar@example.com | bridgetest@protonmail.com | two | 2020-01-01T13:00:00 |
| bar@example.com | bridgetest@protonmail.com | three | 2020-01-01T12:30:00 |
Scenario: Export all
When user "user" exports to MBOX files
Then progress result is "OK"
# Every message is also in All Mail.
And transfer exported 8 messages
And transfer imported 8 messages
And transfer failed for 0 messages
And transfer exported messages
| folder | from | to | subject | time |
| Inbox | bridgetest@pm.test | test@protonmail.com | hello | 2020-01-01T12:00:00 |
| Foo | foo@example.com | bridgetest@protonmail.com | one | 2020-01-01T12:00:00 |
| Foo | bar@example.com | bridgetest@protonmail.com | two | 2020-01-01T13:00:00 |
| Foo | bar@example.com | bridgetest@protonmail.com | three | 2020-01-01T12:30:00 |
| All Mail | bridgetest@pm.test | test@protonmail.com | hello | 2020-01-01T12:00:00 |
| All Mail | foo@example.com | bridgetest@protonmail.com | one | 2020-01-01T12:00:00 |
| All Mail | bar@example.com | bridgetest@protonmail.com | two | 2020-01-01T13:00:00 |
| All Mail | bar@example.com | bridgetest@protonmail.com | three | 2020-01-01T12:30:00 |
Scenario: Export only Foo with time limit
When user "user" exports to MBOX files with rules
| source | target | from | to |
| Foo | | 2020-01-01T12:10:00 | 2020-01-01T13:00:00 |
Then progress result is "OK"
And transfer exported 2 messages
And transfer imported 2 messages
And transfer failed for 0 messages
And transfer exported messages
| folder | from | to | subject | time |
| Foo | bar@example.com | bridgetest@protonmail.com | two | 2020-01-01T13:00:00 |
| Foo | bar@example.com | bridgetest@protonmail.com | three | 2020-01-01T12:30:00 |

View File

@ -0,0 +1,60 @@
Feature: Import from EML files
Background:
Given there is connected user "user"
And there is "user" with mailbox "Folders/Foo"
And there is "user" with mailbox "Folders/Bar"
And there are EML files
| file | from | to | subject | time |
| Foo/one.eml | foo@example.com | bridgetest@protonmail.com | one | 2020-01-01T12:00:00 |
| Foo/two.eml | bar@example.com | bridgetest@protonmail.com | two | 2020-01-01T13:00:00 |
| Sub/Foo/three.eml | bar@example.com | bridgetest@protonmail.com | three | 2020-01-01T12:30:00 |
And there is EML file "Inbox/hello.eml"
"""
Subject: hello
From: Bridge Test <bridgetest@pm.test>
To: Internal Bridge <test@protonmail.com>
hello
"""
Scenario: Import all
When user "user" imports local files
Then progress result is "OK"
And transfer exported 4 messages
And transfer imported 4 messages
And transfer failed for 0 messages
And API mailbox "INBOX" for "user" has messages
| from | to | subject |
| bridgetest@pm.test | test@protonmail.com | hello |
And API mailbox "Folders/Foo" for "user" has messages
| from | to | subject |
| foo@example.com | bridgetest@protonmail.com | one |
| bar@example.com | bridgetest@protonmail.com | two |
| bar@example.com | bridgetest@protonmail.com | three |
Scenario: Import only Foo to Bar with time limit
When user "user" imports local files with rules
| source | target | from | to |
| Foo | Bar | 2020-01-01T12:10:00 | 2020-01-01T13:00:00 |
Then progress result is "OK"
And transfer exported 2 messages
And transfer imported 2 messages
And transfer failed for 0 messages
And API mailbox "Folders/Bar" for "user" has messages
| from | to | subject |
| bar@example.com | bridgetest@protonmail.com | two |
| bar@example.com | bridgetest@protonmail.com | three |
Scenario: Import broken EML message
Given there is EML file "Broken/broken.eml"
"""
Content-type: image/png
"""
When user "user" imports local files with rules
| source | target |
| Broken | Foo |
Then progress result is "OK"
And transfer exported 1 messages
And transfer imported 0 messages
And transfer failed for 1 messages

View File

@ -0,0 +1,49 @@
Feature: Import/Export
Background:
Given there is connected user "user"
And there is "user" with mailbox "Folders/Foo"
And there is "user" with mailbox "Folders/Bar"
Scenario: EML -> PM -> EML
Given there are EML files
| file | from | to | subject | time |
| Inbox/hello.eml | bridgetest@pm.test | test@protonmail.com | hello | 2020-01-01T12:00:00 |
| Foo/one.eml | foo@example.com | bridgetest@protonmail.com | one | 2020-01-01T12:00:00 |
| Foo/two.eml | bar@example.com | bridgetest@protonmail.com | two | 2020-01-01T13:00:00 |
| Sub/Foo/three.eml | bar@example.com | bridgetest@protonmail.com | three | 2020-01-01T12:30:00 |
When user "user" imports local files
Then progress result is "OK"
And transfer failed for 0 messages
And transfer imported 4 messages
When user "user" exports to EML files
Then progress result is "OK"
And transfer failed for 0 messages
# Every message is also in All Mail.
And transfer imported 8 messages
And exported messages match the original ones
Scenario: MBOX -> PM -> MBOX
Given there is MBOX file "Inbox.mbox" with messages
| from | to | subject | time |
| bridgetest@pm.test | test@protonmail.com | hello | 2020-01-01T12:00:00 |
And there is MBOX file "Foo.mbox" with messages
| from | to | subject | time |
| foo@example.com | bridgetest@protonmail.com | one | 2020-01-01T12:00:00 |
| bar@example.com | bridgetest@protonmail.com | two | 2020-01-01T13:00:00 |
| bar@example.com | bridgetest@protonmail.com | three | 2020-01-01T12:30:00 |
When user "user" imports local files
Then progress result is "OK"
And transfer failed for 0 messages
And transfer imported 4 messages
When user "user" exports to MBOX files
Then progress result is "OK"
And transfer failed for 0 messages
# Every message is also in All Mail.
And transfer imported 8 messages
And exported messages match the original ones

View File

@ -0,0 +1,79 @@
Feature: Import from IMAP server
Background:
Given there is connected user "user"
And there is "user" with mailbox "Folders/Foo"
And there is "user" with mailbox "Folders/Bar"
And there are IMAP mailboxes
| name |
| Inbox |
| Foo |
| Broken |
And there are IMAP messages
| mailbox | seqnum | uid | from | to | subject | time |
| Foo | 1 | 12 | foo@example.com | bridgetest@protonmail.com | one | 2020-01-01T12:00:00 |
| Foo | 2 | 14 | bar@example.com | bridgetest@protonmail.com | two | 2020-01-01T13:00:00 |
| Foo | 3 | 15 | bar@example.com | bridgetest@protonmail.com | three | 2020-01-01T12:30:00 |
And there is IMAP message in mailbox "Inbox" with seq 1, uid 42, time "2020-01-01T12:34:56" and subject "hello"
"""
Subject: hello
From: Bridge Test <bridgetest@pm.test>
To: Internal Bridge <test@protonmail.com>
hello
"""
Scenario: Import all
When user "user" imports remote messages
Then progress result is "OK"
And transfer exported 4 messages
And transfer imported 4 messages
And transfer failed for 0 messages
And API mailbox "INBOX" for "user" has messages
| from | to | subject |
| bridgetest@pm.test | test@protonmail.com | hello |
And API mailbox "Folders/Foo" for "user" has messages
| from | to | subject |
| foo@example.com | bridgetest@protonmail.com | one |
| bar@example.com | bridgetest@protonmail.com | two |
| bar@example.com | bridgetest@protonmail.com | three |
Scenario: Import only Foo to Bar with time limit
When user "user" imports remote messages with rules
| source | target | from | to |
| Foo | Bar | 2020-01-01T12:10:00 | 2020-01-01T13:00:00 |
Then progress result is "OK"
And transfer exported 2 messages
And transfer imported 2 messages
And transfer failed for 0 messages
And API mailbox "Folders/Bar" for "user" has messages
| from | to | subject |
| bar@example.com | bridgetest@protonmail.com | two |
| bar@example.com | bridgetest@protonmail.com | three |
# Note we need to have message which we can parse and use in go-imap
# but which has problem on our side. Used example with missing boundary
# is real example which we want to solve one day. Probabl this test
# can be removed once we import any time of message or switch is to
# something we will never allow.
Scenario: Import broken message
Given there is IMAP message in mailbox "Broken" with seq 1, uid 42, time "2020-01-01T12:34:56" and subject "broken"
"""
Subject: missing boundary end
Content-Type: multipart/related; boundary=boundary
--boundary
Content-Disposition: inline
Content-Transfer-Encoding: quoted-printable
Content-Type: text/plain; charset=utf-8
body
"""
When user "user" imports remote messages with rules
| source | target |
| Broken | Foo |
Then progress result is "OK"
And transfer exported 1 messages
And transfer imported 0 messages
And transfer failed for 1 messages

View File

@ -0,0 +1,64 @@
Feature: Import from MBOX files
Background:
Given there is connected user "user"
And there is "user" with mailbox "Folders/Foo"
And there is "user" with mailbox "Folders/Bar"
And there is MBOX file "Foo.mbox" with messages
| from | to | subject | time |
| foo@example.com | bridgetest@protonmail.com | one | 2020-01-01T12:00:00 |
| bar@example.com | bridgetest@protonmail.com | two | 2020-01-01T13:00:00 |
And there is MBOX file "Sub/Foo.mbox" with messages
| from | to | subject | time |
| bar@example.com | bridgetest@protonmail.com | three | 2020-01-01T12:30:00 |
And there is MBOX file "Inbox.mbox"
"""
From bridgetest@pm.test Thu Feb 20 20:20:20 2020
Subject: hello
From: Bridge Test <bridgetest@pm.test>
To: Internal Bridge <test@protonmail.com>
hello
"""
Scenario: Import all
When user "user" imports local files
Then progress result is "OK"
And transfer exported 4 messages
And transfer imported 4 messages
And transfer failed for 0 messages
And API mailbox "INBOX" for "user" has messages
| from | to | subject |
| bridgetest@pm.test | test@protonmail.com | hello |
And API mailbox "Folders/Foo" for "user" has messages
| from | to | subject |
| foo@example.com | bridgetest@protonmail.com | one |
| bar@example.com | bridgetest@protonmail.com | two |
| bar@example.com | bridgetest@protonmail.com | three |
Scenario: Import only Foo to Bar with time limit
When user "user" imports local files with rules
| source | target | from | to |
| Foo | Bar | 2020-01-01T12:10:00 | 2020-01-01T13:00:00 |
Then progress result is "OK"
And transfer exported 2 messages
And transfer imported 2 messages
And transfer failed for 0 messages
And API mailbox "Folders/Bar" for "user" has messages
| from | to | subject |
| bar@example.com | bridgetest@protonmail.com | two |
| bar@example.com | bridgetest@protonmail.com | three |
Scenario: Import broken message
Given there is MBOX file "Broken.mbox"
"""
From bridgetest@pm.test Thu Feb 20 20:20:20 2020
Content-type: image/png
"""
When user "user" imports local files with rules
| source | target |
| Broken | Foo |
Then progress result is "OK"
And transfer exported 1 messages
And transfer imported 0 messages
And transfer failed for 1 messages

View File

@ -0,0 +1,20 @@
Feature: Delete user
Scenario: Deleting connected user
Given there is connected user "user"
When user deletes "user"
Then last response is "OK"
Scenario: Deleting connected user with cache
Given there is connected user "user"
When user deletes "user" with cache
Then last response is "OK"
Scenario: Deleting disconnected user
Given there is disconnected user "user"
When user deletes "user"
Then last response is "OK"
Scenario: Deleting disconnected user with cache
Given there is disconnected user "user"
When user deletes "user" with cache
Then last response is "OK"

View File

@ -0,0 +1,56 @@
Feature: Login for the first time
Scenario: Normal login
Given there is user "user"
When "user" logs in
Then last response is "OK"
And "user" is connected
Scenario: Login with bad username
When "user" logs in with bad password
Then last response is "failed to login: Incorrect login credentials. Please try again"
Scenario: Login with bad password
Given there is user "user"
When "user" logs in with bad password
Then last response is "failed to login: Incorrect login credentials. Please try again"
Scenario: Login without internet connection
Given there is no internet connection
When "user" logs in
Then last response is "failed to login: cannot reach the server"
@ignore-live
Scenario: Login user with 2FA
Given there is user "user2fa"
When "user2fa" logs in
Then last response is "OK"
And "user2fa" is connected
Scenario: Login user with capital letters in address
Given there is user "userAddressWithCapitalLetter"
When "userAddressWithCapitalLetter" logs in
Then last response is "OK"
And "userAddressWithCapitalLetter" is connected
Scenario: Login user with more addresses
Given there is user "userMoreAddresses"
When "userMoreAddresses" logs in
Then last response is "OK"
And "userMoreAddresses" is connected
@ignore-live
Scenario: Login user with disabled primary address
Given there is user "userDisabledPrimaryAddress"
When "userDisabledPrimaryAddress" logs in
Then last response is "OK"
And "userDisabledPrimaryAddress" is connected
Scenario: Login two users
Given there is user "user"
And there is user "userMoreAddresses"
When "user" logs in
Then last response is "OK"
And "user" is connected
When "userMoreAddresses" logs in
Then last response is "OK"
And "userMoreAddresses" is connected

View File

@ -0,0 +1,12 @@
Feature: Re-login
Scenario: Re-login with connected user
Given there is connected user "user"
When "user" logs in
Then last response is "failed to finish login: user is already connected"
And "user" is connected
Scenario: Re-login with disconnected user
Given there is disconnected user "user"
When "user" logs in
Then last response is "OK"
And "user" is connected

View File

@ -135,3 +135,31 @@ func (ctl *Controller) GetMessageID(username, messageIndex string) string {
}
return ctl.messageIDsByUsername[username][idx-1]
}
func (ctl *Controller) GetMessages(username, labelID string) ([]*pmapi.Message, error) {
client, ok := ctl.pmapiByUsername[username]
if !ok {
return nil, fmt.Errorf("user %s does not exist", username)
}
page := 0
messages := []*pmapi.Message{}
for {
// ListMessages returns empty result, not error, asking for page out of range.
pageMessages, _, err := client.ListMessages(&pmapi.MessagesFilter{
Page: page,
PageSize: 150,
LabelID: labelID,
})
if err != nil {
return nil, errors.Wrap(err, "failed to list messages")
}
messages = append(messages, pageMessages...)
if len(pageMessages) < 150 {
break
}
}
return messages, nil
}

View File

@ -18,13 +18,13 @@
package mocks
import (
"bufio"
"fmt"
"io"
"regexp"
"strings"
"time"
"github.com/emersion/go-imap"
"github.com/pkg/errors"
a "github.com/stretchr/testify/assert"
)
@ -37,7 +37,7 @@ type IMAPResponse struct {
done bool
}
func (ir *IMAPResponse) sendCommand(reqTag string, reqIndex int, command string, debug *debug, conn io.Writer, response *bufio.Reader) {
func (ir *IMAPResponse) sendCommand(reqTag string, reqIndex int, command string, debug *debug, conn io.Writer, response imap.StringReader) {
defer func() { ir.done = true }()
tstart := time.Now()

227
test/mocks/imap_server.go Normal file
View File

@ -0,0 +1,227 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// ProtonMail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package mocks
import (
"fmt"
"net"
"strings"
"time"
"github.com/emersion/go-imap"
imapbackend "github.com/emersion/go-imap/backend"
imapserver "github.com/emersion/go-imap/server"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
type IMAPServer struct {
Username string
Password string
Host string
Port string
mailboxes []string
messages map[string][]*imap.Message // Key is mailbox.
server *imapserver.Server
}
func NewIMAPServer(username, password, host, port string) *IMAPServer {
return &IMAPServer{
Username: username,
Password: password,
Host: host,
Port: port,
mailboxes: []string{},
messages: map[string][]*imap.Message{},
}
}
func (s *IMAPServer) AddMailbox(mailboxName string) {
s.mailboxes = append(s.mailboxes, mailboxName)
s.messages[strings.ToLower(mailboxName)] = []*imap.Message{}
}
func (s *IMAPServer) AddMessage(mailboxName string, message *imap.Message) {
mailboxName = strings.ToLower(mailboxName)
s.messages[mailboxName] = append(s.messages[mailboxName], message)
}
func (s *IMAPServer) Start() {
server := imapserver.New(&IMAPBackend{server: s})
server.Addr = net.JoinHostPort(s.Host, s.Port)
server.AllowInsecureAuth = true
server.ErrorLog = logrus.WithField("pkg", "imap-server")
server.Debug = logrus.WithField("pkg", "imap-server").WriterLevel(logrus.DebugLevel)
server.AutoLogout = 30 * time.Minute
s.server = server
go func() {
err := server.ListenAndServe()
logrus.WithError(err).Warn("IMAP server stopped")
}()
time.Sleep(100 * time.Millisecond)
}
func (s *IMAPServer) Stop() {
_ = s.server.Close()
}
type IMAPBackend struct {
server *IMAPServer
}
func (b *IMAPBackend) Login(connInfo *imap.ConnInfo, username, password string) (imapbackend.User, error) {
if username != b.server.Username || password != b.server.Password {
return nil, errors.New("invalid credentials")
}
return &IMAPUser{
server: b.server,
username: username,
}, nil
}
type IMAPUser struct {
server *IMAPServer
username string
}
func (u *IMAPUser) Username() string {
return u.username
}
func (u *IMAPUser) ListMailboxes(subscribed bool) ([]imapbackend.Mailbox, error) {
mailboxes := []imapbackend.Mailbox{}
for _, mailboxName := range u.server.mailboxes {
mailboxes = append(mailboxes, &IMAPMailbox{
server: u.server,
name: mailboxName,
})
}
return mailboxes, nil
}
func (u *IMAPUser) GetMailbox(name string) (imapbackend.Mailbox, error) {
name = strings.ToLower(name)
_, ok := u.server.messages[name]
if !ok {
return nil, fmt.Errorf("mailbox %s not found", name)
}
return &IMAPMailbox{
server: u.server,
name: name,
}, nil
}
func (u *IMAPUser) CreateMailbox(name string) error {
return errors.New("not supported: create mailbox")
}
func (u *IMAPUser) DeleteMailbox(name string) error {
return errors.New("not supported: delete mailbox")
}
func (u *IMAPUser) RenameMailbox(existingName, newName string) error {
return errors.New("not supported: rename mailbox")
}
func (u *IMAPUser) Logout() error {
return nil
}
type IMAPMailbox struct {
server *IMAPServer
name string
attributes []string
}
func (m *IMAPMailbox) Name() string {
return m.name
}
func (m *IMAPMailbox) Info() (*imap.MailboxInfo, error) {
return &imap.MailboxInfo{
Name: m.name,
Attributes: m.attributes,
}, nil
}
func (m *IMAPMailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, error) {
status := imap.NewMailboxStatus(m.name, items)
status.UidValidity = 1
status.Messages = uint32(len(m.server.messages[m.name]))
return status, nil
}
func (m *IMAPMailbox) SetSubscribed(subscribed bool) error {
return errors.New("not supported: set subscribed")
}
func (m *IMAPMailbox) Check() error {
return errors.New("not supported: check")
}
func (m *IMAPMailbox) ListMessages(uid bool, seqset *imap.SeqSet, items []imap.FetchItem, ch chan<- *imap.Message) error {
defer func() {
close(ch)
}()
for index, message := range m.server.messages[m.name] {
seqNum := uint32(index + 1)
var id uint32
if uid {
id = message.Uid
} else {
id = seqNum
}
if seqset.Contains(id) {
msg := imap.NewMessage(seqNum, items)
msg.Envelope = message.Envelope
msg.BodyStructure = message.BodyStructure
msg.Body = message.Body
msg.Size = message.Size
msg.Uid = message.Uid
ch <- msg
}
}
return nil
}
func (m *IMAPMailbox) SearchMessages(uid bool, criteria *imap.SearchCriteria) ([]uint32, error) {
return nil, errors.New("not supported: search")
}
func (m *IMAPMailbox) CreateMessage(flags []string, date time.Time, body imap.Literal) error {
return errors.New("not supported: create")
}
func (m *IMAPMailbox) UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, operation imap.FlagsOp, flags []string) error {
return errors.New("not supported: update flags")
}
func (m *IMAPMailbox) CopyMessages(uid bool, seqset *imap.SeqSet, dest string) error {
return errors.New("not supported: copy")
}
func (m *IMAPMailbox) Expunge() error {
return errors.New("not supported: expunge")
}

View File

@ -172,12 +172,12 @@ func messagesContainsMessageRow(account *accounts.TestAccount, allMessages []*pm
matches := true
for n, cell := range row.Cells {
switch head[n].Value {
case "from":
case "from": //nolint[goconst]
address := ctx.EnsureAddress(account.Username(), cell.Value)
if !areAddressesSame(message.Sender.Address, address) {
matches = false
}
case "to":
case "to": //nolint[goconst]
for _, address := range strings.Split(cell.Value, ",") {
address = ctx.EnsureAddress(account.Username(), address)
for _, to := range message.ToList {
@ -197,7 +197,7 @@ func messagesContainsMessageRow(account *accounts.TestAccount, allMessages []*pm
}
}
}
case "subject":
case "subject": //nolint[goconst]
expectedSubject := cell.Value
if expectedSubject == "" {
expectedSubject = "(No Subject)"
@ -205,7 +205,7 @@ func messagesContainsMessageRow(account *accounts.TestAccount, allMessages []*pm
if message.Subject != expectedSubject {
matches = false
}
case "body":
case "body": //nolint[goconst]
if message.Body != cell.Value {
matches = false
}
@ -238,7 +238,7 @@ func areAddressesSame(first, second string) bool {
if err != nil {
return false
}
return firstAddress.String() == secondAddress.String()
return firstAddress.Address == secondAddress.Address
}
func messagesInMailboxForUserIsMarkedAsRead(messageIDs, mailboxName, bddUserID string) error {

Some files were not shown because too many files have changed in this diff Show More