Compare commits

..

34 Commits

Author SHA1 Message Date
cd8db6fd1c chore: Alcantara Bridge 3.11.1 changelog. 2024-05-16 15:12:56 +02:00
a5e0f85a58 fix(BRIDGE-70): hotfix for blocked smtp/imap port causing bridge to quit 2024-05-16 09:51:32 +02:00
82607efe1c chore: Alcantara Bridge 3.11.0 changelog. 2024-04-23 17:07:24 +02:00
961dc9435f fix(BRIDGE-15): Apple Mail profile install page was not properly reset before showing. 2024-04-23 15:58:22 +02:00
b574ccb6ea chore: Alcantara Bridge 3.11.0 changelog. 2024-04-22 10:37:47 +02:00
2569e83e51 chore: Alcantara Bridge 3.11.0 changelog. 2024-04-22 09:27:43 +02:00
d9fdbb35bc fix(GODT-3185): logic mistake. 2024-04-22 07:26:18 +00:00
5769fb9466 ci: windows build missing revision 2024-04-19 10:39:47 +02:00
a4020cebd4 chore: do not use C++ 20 std::ranges. 2024-04-19 08:03:18 +02:00
7a8760e2ef fix(BRIDGE-19): warning instead of error on logs for checksum validation... 2024-04-17 12:59:36 +00:00
9552e72ba8 feat(BRIDGE-14): HV3 implementation - GUI & CLI; ownership verification & CAPTCHA are supported 2024-04-12 13:07:22 +00:00
c692c21b87 fix(BRIDGE-8): more robust command-line args parser in bridge-gui.
fix(BRIDGE-8): add command-line invocation to log.
2024-04-12 11:16:59 +00:00
bb15efa711 fix(BRIDGE-8): launcher replace session-id if provided instead of adding another one. 2024-04-12 11:16:59 +00:00
e94d3be12d chore: bump testing context bridge version 2024-04-12 11:55:53 +02:00
66569f71a0 fix(BRIDGE-7): add timestamp to test credentials for keychain on macOS. 2024-04-09 10:43:31 +02:00
9bfa79455e fix(BRIDGE-7): modify keychain test on macOS. 2024-04-08 14:35:36 +02:00
67e802e3a0 feat(BRIDGE-15): fix a stack layout index in comment. 2024-04-08 11:27:28 +02:00
8a5e2007f6 feat(BRIDGE-15): certificate install is now also done during Outlook setup on macOS. 2024-04-04 08:57:30 +02:00
5b92945626 chore: disable GO-2024-2687 in govulncheck 2024-04-04 07:58:16 +02:00
4a8a7ef093 fix(BRIDGE-4): logs not being created when invalid flag is passed 2024-03-21 16:32:12 +00:00
2cfda14b1a fix(BRIDGE-5): add tooltip to tray icon. 2024-03-20 14:55:40 +01:00
312993e08e feat(GODT-3253): windows cache and paths. 2024-03-15 11:28:52 +01:00
b1110b04c9 feat(GODT-3253): make paths. 2024-03-15 11:27:33 +01:00
d2bc60d9cb ci: debug 2024-03-15 11:27:29 +01:00
1d8f6c75c8 feat(GODT-3253): use new virtual machine for windows jobs. bump vcpkg to 2024.02.14 2024-03-15 11:23:46 +01:00
06daaf8d9f feat(GODT-3146): don't need to wait for IMAP in tests. 2024-03-14 11:57:55 +01:00
cb436fff63 feat(GODT-3146): remove unused 2024-03-13 14:31:53 +01:00
921a44f1a3 feat(GODT-3146): keep imap/smtp server always on. 2024-03-13 14:22:23 +01:00
d35af6b686 chore: added bridge-rollout to CI. 2024-03-13 09:25:40 +00:00
4cb938c57f chore: added bridge-rollout cli tool. 2024-03-13 09:25:40 +00:00
232e98d812 chore: Zaehringen Bridge 3.10.0 changelog. 2024-03-13 10:21:52 +01:00
6fadbde4a6 feat(GODT-3185): report cases which leads to wrong address key used 2024-03-13 07:49:25 +00:00
d2fbbc3e25 fix(GODT-3163): filter MBOX format delimiter. 2024-03-07 12:30:33 +00:00
1c7c342e19 ci(GODT-3304): ignore go vulncheck until go version bumped. 2024-03-07 13:00:16 +01:00
89 changed files with 3290 additions and 1864 deletions

View File

@ -3,6 +3,31 @@
Changelog [format](http://keepachangelog.com/en/1.0.0/) Changelog [format](http://keepachangelog.com/en/1.0.0/)
## Alcantara Bridge 3.11.1
### Fixed
* BRIDGE-70: Hotfix for blocked smtp/imap port causing bridge to quit.
## Alcantara Bridge 3.11.0
### Added
* GODT-3185: Report cases which leads to wrong address key used.
### Changed
* BRIDGE-14: HV3 implementation.
* BRIDGE-15: Certificate install is now also done during Outlook setup on macOS.
* GODT-3146: Start servers on startup, keep running even when no users are active.
* BRIDGE-19: Update checksum validation use warning instead of error on non-existing files.
### Fixed
* BRIDGE-8: Fix bridge double sessionID issue in logs.
* BRIDGE-7: Modify keychain test on macOS.
* BRIDGE-4: Logs not being created when invalid flag is passed.
* BRIDGE-5: Add tooltip to tray icon.
* GODT-3163: Filter MBOX format delimiter.
## Zaehringen Bridge 3.10.0 ## Zaehringen Bridge 3.10.0
### Added ### Added

View File

@ -1,17 +1,18 @@
export GO111MODULE=on export GO111MODULE=on
export CGO_ENABLED=1
# By default, the target OS is the same as the host OS, # By default, the target OS is the same as the host OS,
# but this can be overridden by setting TARGET_OS to "windows"/"darwin"/"linux". # but this can be overridden by setting TARGET_OS to "windows"/"darwin"/"linux".
GOOS:=$(shell go env GOOS) GOOS:=$(shell go env GOOS)
TARGET_CMD?=Desktop-Bridge TARGET_CMD?=Desktop-Bridge
TARGET_OS?=${GOOS} TARGET_OS?=${GOOS}
ROOT_DIR:=$(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) ROOT_DIR:=$(realpath .)
## Build ## Build
.PHONY: build build-gui build-nogui build-launcher versioner hasher .PHONY: build build-gui build-nogui build-launcher versioner hasher
# Keep version hardcoded so app build works also without Git repository. # Keep version hardcoded so app build works also without Git repository.
BRIDGE_APP_VERSION?=3.10.0+git BRIDGE_APP_VERSION?=3.11.1+git
APP_VERSION:=${BRIDGE_APP_VERSION} APP_VERSION:=${BRIDGE_APP_VERSION}
APP_FULL_NAME:=Proton Mail Bridge APP_FULL_NAME:=Proton Mail Bridge
APP_VENDOR:=Proton AG APP_VENDOR:=Proton AG
@ -19,8 +20,8 @@ SRC_ICO:=bridge.ico
SRC_ICNS:=Bridge.icns SRC_ICNS:=Bridge.icns
SRC_SVG:=bridge.svg SRC_SVG:=bridge.svg
EXE_NAME:=proton-bridge EXE_NAME:=proton-bridge
REVISION:=$(shell ./utils/get_revision.sh) REVISION:=$(shell "${ROOT_DIR}/utils/get_revision.sh" rev)
TAG:=$(shell ./utils/get_revision.sh tag) TAG:=$(shell "${ROOT_DIR}/utils/get_revision.sh" tag)
BUILD_TIME:=$(shell date +%FT%T%z) BUILD_TIME:=$(shell date +%FT%T%z)
MACOS_MIN_VERSION_ARM64=11.0 MACOS_MIN_VERSION_ARM64=11.0
MACOS_MIN_VERSION_AMD64=10.15 MACOS_MIN_VERSION_AMD64=10.15
@ -101,9 +102,9 @@ endif
ifeq "${GOOS}" "windows" ifeq "${GOOS}" "windows"
go-build-finalize= \ go-build-finalize= \
$(if $(4),powershell Copy-Item ${ROOT_DIR}/${RESOURCE_FILE} ${4} &&,) \ $(if $(4),cp "${ROOT_DIR}/${RESOURCE_FILE}" ${4} &&,) \
$(call go-build,$(1),$(2),$(3)) \ $(call go-build,$(1),$(2),$(3)) \
$(if $(4), && powershell Remove-Item ${4} -Force,) $(if $(4), && rm -f ${4},)
endif endif
${EXE_NAME}: gofiles ${RESOURCE_FILE} ${EXE_NAME}: gofiles ${RESOURCE_FILE}
@ -117,7 +118,10 @@ versioner:
go build ${BUILD_FLAGS} -o versioner utils/versioner/main.go go build ${BUILD_FLAGS} -o versioner utils/versioner/main.go
vault-editor: vault-editor:
$(call go-build-finalize,"-tags=debug","vault-editor","./utils/vault-editor/main.go") $(call go-build-finalize,-tags=debug,"vault-editor","./utils/vault-editor/main.go")
bridge-rollout:
$(call go-build-finalize,, "bridge-rollout","./utils/bridge-rollout/bridge-rollout.go")
hasher: hasher:
go build -o hasher utils/hasher/main.go go build -o hasher utils/hasher/main.go
@ -164,7 +168,7 @@ ${EXE_TARGET}: check-build-essentials ${EXE_NAME}
BRIDGE_BUILD_TIME=${BUILD_TIME} \ BRIDGE_BUILD_TIME=${BUILD_TIME} \
BRIDGE_GUI_BUILD_CONFIG=Release \ BRIDGE_GUI_BUILD_CONFIG=Release \
BRIDGE_BUILD_ENV=${BUILD_ENV} \ BRIDGE_BUILD_ENV=${BUILD_ENV} \
BRIDGE_INSTALL_PATH=${ROOT_DIR}/${DEPLOY_DIR}/${GOOS} \ BRIDGE_INSTALL_PATH="${ROOT_DIR}/${DEPLOY_DIR}/${GOOS}" \
./build.sh install ./build.sh install
mv "${ROOT_DIR}/${BRIDGE_EXE}" "$(ROOT_DIR)/${EXE_TARGET}" mv "${ROOT_DIR}/${BRIDGE_EXE}" "$(ROOT_DIR)/${EXE_TARGET}"

View File

@ -1,4 +1,3 @@
--- ---
.script-build: .script-build:
@ -7,9 +6,14 @@
extends: extends:
- .rules-branch-and-MR-manual - .rules-branch-and-MR-manual
script: script:
- which go && go version
- which gcc && gcc --version
- which qmake && qmake --version
- git rev-parse --short=10 HEAD
- make build - make build
- git diff && git diff-index --quiet HEAD - git diff && git diff-index --quiet HEAD
- make vault-editor - make vault-editor
- make bridge-rollout
artifacts: artifacts:
expire_in: 1 day expire_in: 1 day
when: always when: always
@ -17,7 +21,7 @@
paths: paths:
- bridge_*.tgz - bridge_*.tgz
- vault-editor - vault-editor
- bridge-rollout
build-linux: build-linux:
extends: extends:
- .script-build - .script-build
@ -66,4 +70,3 @@ trigger-qa-installer:
trigger: trigger:
project: "jcuth/bridge-release" project: "jcuth/bridge-release"
branch: master branch: master

View File

@ -2,27 +2,35 @@
--- ---
.env-windows: .env-windows:
extends:
- .image-windows-virt-build
before_script: before_script:
- export BRIDGE_SYNC_FORCE_MINIMUM_SPEC=1 - !reference [.before-script-windows-virt-build, before_script]
- !reference [.before-script-windows-aws-build, before_script]
- !reference [.before-script-git-config, before_script] - !reference [.before-script-git-config, before_script]
- git config --global safe.directory '*' - mkdir -p .cache/bin
- git status --porcelain - export PATH=$(pwd)/.cache/bin:$PATH
cache: {} - export GOPATH="$CI_PROJECT_DIR/.cache"
tags: variables:
- windows-bridge GOARCH: amd64
BRIDGE_SYNC_FORCE_MINIMUM_SPEC: 1
VCPKG_DEFAULT_BINARY_CACHE: ${CI_PROJECT_DIR}/.cache
cache:
key: windows-vcpkg-go-0
paths:
- .cache
when: 'always'
.env-darwin: .env-darwin:
extends: extends:
- .image-darwin-build - .image-darwin-build
before_script: before_script:
- export BRIDGE_SYNC_FORCE_MINIMUM_SPEC=1
- !reference [.before-script-darwin-tart-build, before_script] - !reference [.before-script-darwin-tart-build, before_script]
- !reference [.before-script-git-config, before_script] - !reference [.before-script-git-config, before_script]
- mkdir -p .cache/bin - mkdir -p .cache/bin
- export PATH=$(pwd)/.cache/bin:$PATH - export PATH=$(pwd)/.cache/bin:$PATH
- export GOPATH="$CI_PROJECT_DIR/.cache" - export GOPATH="$CI_PROJECT_DIR/.cache"
variables: variables:
BRIDGE_SYNC_FORCE_MINIMUM_SPEC: 1
VCPKG_DEFAULT_BINARY_CACHE: ${CI_PROJECT_DIR}/.cache VCPKG_DEFAULT_BINARY_CACHE: ${CI_PROJECT_DIR}/.cache
cache: cache:
key: darwin-go-and-vcpkg key: darwin-go-and-vcpkg

View File

@ -26,11 +26,15 @@ lint-bug-report-preview:
extends: extends:
- .rules-branch-manual-MR-and-devel-always - .rules-branch-manual-MR-and-devel-always
script: script:
- which go && go version
- which gcc && gcc --version
- make test - make test
artifacts: artifacts:
paths: paths:
- coverage/** - coverage/**
test-linux: test-linux:
extends: extends:
- .image-linux-test - .image-linux-test
@ -70,6 +74,9 @@ test-integration:
- test-linux - test-linux
script: script:
- make test-integration | tee -a integration-job.log - make test-integration | tee -a integration-job.log
after_script:
- |
grep "Error: " integration-job.log
artifacts: artifacts:
when: always when: always
paths: paths:
@ -95,6 +102,9 @@ test-integration-nightly:
- test-integration - test-integration
script: script:
- make test-integration-nightly | tee -a nightly-job.log - make test-integration-nightly | tee -a nightly-job.log
after_script:
- |
grep "Error: " nightly-job.log
artifacts: artifacts:
when: always when: always
paths: paths:

View File

@ -19,8 +19,15 @@ package main
import ( import (
"os" "os"
"runtime"
"strings" "strings"
"github.com/ProtonMail/proton-bridge/v3/internal/constants"
"github.com/ProtonMail/proton-bridge/v3/internal/locations"
"github.com/ProtonMail/proton-bridge/v3/internal/logging"
"github.com/ProtonMail/proton-bridge/v3/internal/sentry"
"github.com/sirupsen/logrus"
"github.com/ProtonMail/proton-bridge/v3/internal/app" "github.com/ProtonMail/proton-bridge/v3/internal/app"
"github.com/bradenaw/juniper/xslices" "github.com/bradenaw/juniper/xslices"
) )
@ -43,5 +50,72 @@ import (
*/ */
func main() { func main() {
_ = app.New().Run(xslices.Filter(os.Args, func(arg string) bool { return !strings.Contains(arg, "-psn_") })) appErr := app.New().Run(xslices.Filter(os.Args, func(arg string) bool { return !strings.Contains(arg, "-psn_") }))
if appErr != nil {
_ = app.WithLocations(func(l *locations.Locations) error {
logsPath, err := l.ProvideLogsPath()
if err != nil {
return err
}
// Get the session ID if its specified
var sessionID logging.SessionID
if flagVal, found := getFlagValue(os.Args, app.FlagSessionID); found {
sessionID = logging.SessionID(flagVal)
} else {
sessionID = logging.NewSessionID()
}
closer, err := logging.Init(
logsPath,
sessionID,
logging.BridgeShortAppName,
logging.DefaultMaxLogFileSize,
logging.DefaultPruningSize,
"",
)
if err != nil {
return err
}
defer func() {
_ = logging.Close(closer)
}()
logrus.
WithField("appName", constants.FullAppName).
WithField("version", constants.Version).
WithField("revision", constants.Revision).
WithField("tag", constants.Tag).
WithField("build", constants.BuildTime).
WithField("runtime", runtime.GOOS).
WithField("args", os.Args).
WithField("SentryID", sentry.GetProtectedHostname()).WithError(appErr).Error("Failed to initialize bridge")
return nil
})
}
}
// getFlagValue - obtains the value of a specified tag
// The flag can be of the following form `-flag value`, `--flag value`, `-flag=value` or `--flags=value`.
func getFlagValue(argList []string, flag string) (string, bool) {
eqPrefix1 := "-" + flag + "="
eqPrefix2 := "--" + flag + "="
for i := 0; i < len(argList); i++ {
arg := argList[i]
if strings.HasPrefix(arg, eqPrefix1) {
val := strings.TrimPrefix(arg, eqPrefix1)
return val, len(val) > 0
}
if strings.HasPrefix(arg, eqPrefix2) {
val := strings.TrimPrefix(arg, eqPrefix2)
return val, len(val) > 0
}
if (arg == "-"+flag || arg == "--"+flag) && i+1 < len(argList) {
return argList[i+1], true
}
}
return "", false
} }

View File

@ -0,0 +1,47 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package main
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestGetFlagValue(t *testing.T) {
tests := []struct {
args []string
flag string
expected string
}{
{[]string{"session-id", ""}, "session-id", ""},
{[]string{"-session-id", ""}, "session-id", ""},
{[]string{"--session-id", ""}, "session-id", ""},
{[]string{"session-id", "test"}, "session-id", ""},
{[]string{"-session-id", "test"}, "session-id", "test"},
{[]string{"--session-id", "test"}, "session-id", "test"},
{[]string{"session-id=test"}, "session-id", ""},
{[]string{"-session-id=test"}, "session-id", "test"},
{[]string{"--session-id=test"}, "session-id", "test"},
}
for _, tt := range tests {
val, _ := getFlagValue(tt.args, tt.flag)
require.Equal(t, val, tt.expected)
}
}

View File

@ -40,6 +40,7 @@ import (
"github.com/elastic/go-sysinfo/types" "github.com/elastic/go-sysinfo/types"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"golang.org/x/exp/slices"
"golang.org/x/sys/execabs" "golang.org/x/sys/execabs"
) )
@ -53,9 +54,12 @@ const (
FlagCLIShort = "c" FlagCLIShort = "c"
FlagNonInteractive = "noninteractive" FlagNonInteractive = "noninteractive"
FlagNonInteractiveShort = "n" FlagNonInteractiveShort = "n"
FlagLauncher = "--launcher" FlagLauncher = "launcher"
FlagWait = "--wait" FlagWait = "wait"
FlagSessionID = "--session-id" FlagSessionID = "session-id"
HyphenatedFlagLauncher = "--" + FlagLauncher
HyphenatedFlagWait = "--" + FlagWait
HyphenatedFlagSessionID = "--" + FlagSessionID
) )
func main() { //nolint:funlen func main() { //nolint:funlen
@ -151,7 +155,7 @@ func main() { //nolint:funlen
} }
} }
cmd := execabs.Command(exe, appendLauncherPath(launcher, append(args, FlagSessionID, string(sessionID)))...) //nolint:gosec cmd := execabs.Command(exe, appendLauncherPath(launcher, appendOrModifySessionID(args, string(sessionID)))...) //nolint:gosec
cmd.Stdin = os.Stdin cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
@ -173,19 +177,14 @@ func main() { //nolint:funlen
// appendLauncherPath add launcher path if missing. // appendLauncherPath add launcher path if missing.
func appendLauncherPath(path string, args []string) []string { func appendLauncherPath(path string, args []string) []string {
if !sliceContains(args, FlagLauncher) { if !slices.Contains(args, HyphenatedFlagLauncher) {
res := append([]string{}, args...) res := append([]string{}, args...)
res = append(res, FlagLauncher, path) res = append(res, HyphenatedFlagLauncher, path)
return res return res
} }
return args return args
} }
// sliceContains checks if a value is present in a list.
func sliceContains[T comparable](list []T, s T) bool {
return xslices.Any(list, func(arg T) bool { return arg == s })
}
// inCLIMode detect if CLI mode is asked. // inCLIMode detect if CLI mode is asked.
func inCLIMode(args []string) bool { func inCLIMode(args []string) bool {
return hasFlag(args, FlagCLI) || hasFlag(args, FlagCLIShort) || hasFlag(args, FlagNonInteractive) || hasFlag(args, FlagNonInteractiveShort) return hasFlag(args, FlagCLI) || hasFlag(args, FlagCLIShort) || hasFlag(args, FlagNonInteractive) || hasFlag(args, FlagNonInteractiveShort)
@ -193,7 +192,12 @@ func inCLIMode(args []string) bool {
// hasFlag checks if a flag is present in a list. // hasFlag checks if a flag is present in a list.
func hasFlag(args []string, flag string) bool { func hasFlag(args []string, flag string) bool {
return xslices.Any(args, func(arg string) bool { return (arg == "-"+flag) || (arg == "--"+flag) }) return flagIndex(args, flag) >= 0
}
// flagIndex returns the position of the first occurrence of a flag int args, or -1 if the flag is not present.
func flagIndex(args []string, flag string) int {
return slices.IndexFunc(args, func(arg string) bool { return (arg == "-"+flag) || (arg == "--"+flag) })
} }
// findAndStrip check if a value is present in s list and remove all occurrences of the value from this list. // findAndStrip check if a value is present in s list and remove all occurrences of the value from this list.
@ -211,7 +215,7 @@ func findAndStripWait(args []string) ([]string, bool, []string) {
hasFlag := false hasFlag := false
values := make([]string, 0) values := make([]string, 0)
for k, v := range res { for k, v := range res {
if v != FlagWait { if v != HyphenatedFlagWait {
continue continue
} }
if k+1 >= len(res) { if k+1 >= len(res) {
@ -222,7 +226,7 @@ func findAndStripWait(args []string) ([]string, bool, []string) {
} }
if hasFlag { if hasFlag {
res, _ = findAndStrip(res, FlagWait) res, _ = findAndStrip(res, HyphenatedFlagWait)
for _, v := range values { for _, v := range values {
res, _ = findAndStrip(res, v) res, _ = findAndStrip(res, v)
} }
@ -230,6 +234,23 @@ func findAndStripWait(args []string) ([]string, bool, []string) {
return res, hasFlag, values return res, hasFlag, values
} }
// return args with the sessionID flag and value added or modified. The original slice is not modified.
func appendOrModifySessionID(args []string, sessionID string) []string {
index := flagIndex(args, FlagSessionID)
if index < 0 {
return append(args, HyphenatedFlagSessionID, sessionID)
}
if index == len(args)-1 {
return append(args, sessionID)
}
res := slices.Clone(args)
res[index+1] = sessionID
return res
}
func getPathToUpdatedExecutable( func getPathToUpdatedExecutable(
name string, name string,
ver *versioner.Versioner, ver *versioner.Versioner,

View File

@ -20,61 +20,62 @@ package main
import ( import (
"testing" "testing"
"github.com/bradenaw/juniper/xslices" "github.com/ProtonMail/proton-bridge/v3/internal/logging"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestSliceContains(t *testing.T) {
assert.True(t, sliceContains([]string{"a", "b", "c"}, "a"))
assert.True(t, sliceContains([]int{1, 2, 3}, 2))
assert.False(t, sliceContains([]string{"a", "b", "c"}, "A"))
assert.False(t, sliceContains([]int{1, 2, 3}, 4))
assert.False(t, sliceContains([]string{}, "a"))
assert.True(t, sliceContains([]string{"a", "a"}, "a"))
}
func TestFindAndStrip(t *testing.T) { func TestFindAndStrip(t *testing.T) {
list := []string{"a", "b", "c", "c", "b", "c"} list := []string{"a", "b", "c", "c", "b", "c"}
result, found := findAndStrip(list, "a") result, found := findAndStrip(list, "a")
assert.True(t, found) assert.True(t, found)
assert.True(t, xslices.Equal(result, []string{"b", "c", "c", "b", "c"})) assert.Equal(t, result, []string{"b", "c", "c", "b", "c"})
result, found = findAndStrip(list, "c") result, found = findAndStrip(list, "c")
assert.True(t, found) assert.True(t, found)
assert.True(t, xslices.Equal(result, []string{"a", "b", "b"})) assert.Equal(t, result, []string{"a", "b", "b"})
result, found = findAndStrip([]string{"c", "c", "c"}, "c") result, found = findAndStrip([]string{"c", "c", "c"}, "c")
assert.True(t, found) assert.True(t, found)
assert.True(t, xslices.Equal(result, []string{})) assert.Equal(t, result, []string{})
result, found = findAndStrip(list, "A") result, found = findAndStrip(list, "A")
assert.False(t, found) assert.False(t, found)
assert.True(t, xslices.Equal(result, list)) assert.Equal(t, result, list)
result, found = findAndStrip([]string{}, "a") result, found = findAndStrip([]string{}, "a")
assert.False(t, found) assert.False(t, found)
assert.True(t, xslices.Equal(result, []string{})) assert.Equal(t, result, []string{})
} }
func TestFindAndStripWait(t *testing.T) { func TestFindAndStripWait(t *testing.T) {
result, found, values := findAndStripWait([]string{"a", "b", "c"}) result, found, values := findAndStripWait([]string{"a", "b", "c"})
assert.False(t, found) assert.False(t, found)
assert.True(t, xslices.Equal(result, []string{"a", "b", "c"})) assert.Equal(t, result, []string{"a", "b", "c"})
assert.True(t, xslices.Equal(values, []string{})) assert.Equal(t, values, []string{})
result, found, values = findAndStripWait([]string{"a", "--wait", "b"}) result, found, values = findAndStripWait([]string{"a", "--wait", "b"})
assert.True(t, found) assert.True(t, found)
assert.True(t, xslices.Equal(result, []string{"a"})) assert.Equal(t, result, []string{"a"})
assert.True(t, xslices.Equal(values, []string{"b"})) assert.Equal(t, values, []string{"b"})
result, found, values = findAndStripWait([]string{"a", "--wait", "b", "--wait", "c"}) result, found, values = findAndStripWait([]string{"a", "--wait", "b", "--wait", "c"})
assert.True(t, found) assert.True(t, found)
assert.True(t, xslices.Equal(result, []string{"a"})) assert.Equal(t, result, []string{"a"})
assert.True(t, xslices.Equal(values, []string{"b", "c"})) assert.Equal(t, values, []string{"b", "c"})
result, found, values = findAndStripWait([]string{"a", "--wait", "b", "--wait", "c", "--wait", "d"}) result, found, values = findAndStripWait([]string{"a", "--wait", "b", "--wait", "c", "--wait", "d"})
assert.True(t, found) assert.True(t, found)
assert.True(t, xslices.Equal(result, []string{"a"})) assert.Equal(t, result, []string{"a"})
assert.True(t, xslices.Equal(values, []string{"b", "c", "d"})) assert.Equal(t, values, []string{"b", "c", "d"})
}
func TestAppendOrModifySessionID(t *testing.T) {
sessionID := string(logging.NewSessionID())
assert.Equal(t, appendOrModifySessionID(nil, sessionID), []string{"--session-id", sessionID})
assert.Equal(t, appendOrModifySessionID([]string{}, sessionID), []string{"--session-id", sessionID})
assert.Equal(t, appendOrModifySessionID([]string{"--cli"}, sessionID), []string{"--cli", "--session-id", sessionID})
assert.Equal(t, appendOrModifySessionID([]string{"--cli", "--session-id"}, sessionID), []string{"--cli", "--session-id", sessionID})
assert.Equal(t, appendOrModifySessionID([]string{"--cli", "--session-id"}, sessionID), []string{"--cli", "--session-id", sessionID})
assert.Equal(t, appendOrModifySessionID([]string{"--session-id", "<oldID>", "--cli"}, sessionID), []string{"--session-id", sessionID, "--cli"})
} }

2
extern/vcpkg vendored

2
go.mod
View File

@ -7,7 +7,7 @@ require (
github.com/Masterminds/semver/v3 v3.2.0 github.com/Masterminds/semver/v3 v3.2.0
github.com/ProtonMail/gluon v0.17.1-0.20240227105633-3734c7694bcd github.com/ProtonMail/gluon v0.17.1-0.20240227105633-3734c7694bcd
github.com/ProtonMail/go-autostart v0.0.0-20210130080809-00ed301c8e9a github.com/ProtonMail/go-autostart v0.0.0-20210130080809-00ed301c8e9a
github.com/ProtonMail/go-proton-api v0.4.1-0.20240226161523-ec58ed7ea4b9 github.com/ProtonMail/go-proton-api v0.4.1-0.20240405124415-8f966ca60436
github.com/ProtonMail/gopenpgp/v2 v2.7.4-proton github.com/ProtonMail/gopenpgp/v2 v2.7.4-proton
github.com/PuerkitoBio/goquery v1.8.1 github.com/PuerkitoBio/goquery v1.8.1
github.com/abiosoft/ishell v2.0.0+incompatible github.com/abiosoft/ishell v2.0.0+incompatible

2
go.sum
View File

@ -40,6 +40,8 @@ github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ek
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw= github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240226161523-ec58ed7ea4b9 h1:tcQpGQljNsZmfuA6L4hAzio8/AIx5OXcU2JUdyX/qxw= github.com/ProtonMail/go-proton-api v0.4.1-0.20240226161523-ec58ed7ea4b9 h1:tcQpGQljNsZmfuA6L4hAzio8/AIx5OXcU2JUdyX/qxw=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240226161523-ec58ed7ea4b9/go.mod h1:t+hb0BfkmZ9fpvzVRpHC7limoowym6ln/j0XL9a8DDw= github.com/ProtonMail/go-proton-api v0.4.1-0.20240226161523-ec58ed7ea4b9/go.mod h1:t+hb0BfkmZ9fpvzVRpHC7limoowym6ln/j0XL9a8DDw=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240405124415-8f966ca60436 h1:ej+W9+UQlb2owkT5arCegmUFkicwesMyFHgBp/wwNg8=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240405124415-8f966ca60436/go.mod h1:t+hb0BfkmZ9fpvzVRpHC7limoowym6ln/j0XL9a8DDw=
github.com/ProtonMail/go-smtp v0.0.0-20231109081432-2b3d50599865 h1:EP1gnxLL5Z7xBSymE9nSTM27nRYINuvssAtDmG0suD8= github.com/ProtonMail/go-smtp v0.0.0-20231109081432-2b3d50599865 h1:EP1gnxLL5Z7xBSymE9nSTM27nRYINuvssAtDmG0suD8=
github.com/ProtonMail/go-smtp v0.0.0-20231109081432-2b3d50599865/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= github.com/ProtonMail/go-smtp v0.0.0-20231109081432-2b3d50599865/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/ProtonMail/go-srp v0.0.7 h1:Sos3Qk+th4tQR64vsxGIxYpN3rdnG9Wf9K4ZloC1JrI= github.com/ProtonMail/go-srp v0.0.7 h1:Sos3Qk+th4tQR64vsxGIxYpN3rdnG9Wf9K4ZloC1JrI=

View File

@ -83,7 +83,7 @@ const (
flagNoWindow = "no-window" flagNoWindow = "no-window"
flagParentPID = "parent-pid" flagParentPID = "parent-pid"
flagSoftwareRenderer = "software-renderer" flagSoftwareRenderer = "software-renderer"
flagSessionID = "session-id" FlagSessionID = "session-id"
) )
const ( const (
@ -165,7 +165,7 @@ func New() *cli.App {
Value: false, Value: false,
}, },
&cli.StringFlag{ &cli.StringFlag{
Name: flagSessionID, Name: FlagSessionID,
Hidden: true, Hidden: true,
}, },
} }
@ -346,7 +346,7 @@ func withLogging(c *cli.Context, crashHandler *crash.Handler, locations *locatio
logrus.WithField("path", logsPath).Debug("Received logs path") logrus.WithField("path", logsPath).Debug("Received logs path")
// Initialize logging. // Initialize logging.
sessionID := logging.NewSessionIDFromString(c.String(flagSessionID)) sessionID := logging.NewSessionIDFromString(c.String(FlagSessionID))
var closer io.Closer var closer io.Closer
if closer, err = logging.Init( if closer, err = logging.Init(
logsPath, logsPath,

View File

@ -43,7 +43,7 @@ import (
// nolint:gosec // nolint:gosec
func migrateKeychainHelper(locations *locations.Locations) error { func migrateKeychainHelper(locations *locations.Locations) error {
logrus.Info("Migrating keychain helper") logrus.Trace("Checking if keychain helper needs to be migrated")
settings, err := locations.ProvideSettingsPath() settings, err := locations.ProvideSettingsPath()
if err != nil { if err != nil {
@ -75,7 +75,11 @@ func migrateKeychainHelper(locations *locations.Locations) error {
return fmt.Errorf("failed to unmarshal old prefs file: %w", err) return fmt.Errorf("failed to unmarshal old prefs file: %w", err)
} }
return vault.SetHelper(settings, prefs.Helper) err = vault.SetHelper(settings, prefs.Helper)
if err == nil {
logrus.Info("Keychain helper has been migrated")
}
return err
} }
// nolint:gosec // nolint:gosec

View File

@ -184,20 +184,11 @@ func TestBridge_UserAgent_Persistence(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, mocks *bridge.Mocks) {
imapWaiter := waitForIMAPServerReady(b)
defer imapWaiter.Done()
smtpWaiter := waitForSMTPServerReady(b)
defer smtpWaiter.Done()
currentUserAgent := b.GetCurrentUserAgent() currentUserAgent := b.GetCurrentUserAgent()
require.Contains(t, currentUserAgent, useragent.DefaultUserAgent) require.Contains(t, currentUserAgent, useragent.DefaultUserAgent)
require.NoError(t, getErr(b.LoginFull(ctx, otherUser, otherPassword, nil, nil))) require.NoError(t, getErr(b.LoginFull(ctx, otherUser, otherPassword, nil, nil)))
imapWaiter.Wait()
smtpWaiter.Wait()
imapClient, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, b.GetIMAPPort())) imapClient, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, b.GetIMAPPort()))
require.NoError(t, err) require.NoError(t, err)
defer func() { _ = imapClient.Logout() }() defer func() { _ = imapClient.Logout() }()
@ -235,21 +226,12 @@ func TestBridge_UserAgentFromUnknownClient(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, mocks *bridge.Mocks) {
imapWaiter := waitForIMAPServerReady(b)
defer imapWaiter.Done()
smtpWaiter := waitForSMTPServerReady(b)
defer smtpWaiter.Done()
currentUserAgent := b.GetCurrentUserAgent() currentUserAgent := b.GetCurrentUserAgent()
require.Contains(t, currentUserAgent, useragent.DefaultUserAgent) require.Contains(t, currentUserAgent, useragent.DefaultUserAgent)
userID, err := b.LoginFull(context.Background(), username, password, nil, nil) userID, err := b.LoginFull(context.Background(), username, password, nil, nil)
require.NoError(t, err) require.NoError(t, err)
imapWaiter.Wait()
smtpWaiter.Wait()
imapClient, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, b.GetIMAPPort())) imapClient, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, b.GetIMAPPort()))
require.NoError(t, err) require.NoError(t, err)
defer func() { _ = imapClient.Logout() }() defer func() { _ = imapClient.Logout() }()
@ -274,21 +256,12 @@ func TestBridge_UserAgentFromSMTPClient(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, mocks *bridge.Mocks) {
imapWaiter := waitForIMAPServerReady(b)
defer imapWaiter.Done()
smtpWaiter := waitForSMTPServerReady(b)
defer smtpWaiter.Done()
currentUserAgent := b.GetCurrentUserAgent() currentUserAgent := b.GetCurrentUserAgent()
require.Contains(t, currentUserAgent, useragent.DefaultUserAgent) require.Contains(t, currentUserAgent, useragent.DefaultUserAgent)
userID, err := b.LoginFull(context.Background(), username, password, nil, nil) userID, err := b.LoginFull(context.Background(), username, password, nil, nil)
require.NoError(t, err) require.NoError(t, err)
imapWaiter.Wait()
smtpWaiter.Wait()
client, err := smtp.Dial(net.JoinHostPort(constants.Host, fmt.Sprint(b.GetSMTPPort()))) client, err := smtp.Dial(net.JoinHostPort(constants.Host, fmt.Sprint(b.GetSMTPPort())))
require.NoError(t, err) require.NoError(t, err)
defer client.Close() //nolint:errcheck defer client.Close() //nolint:errcheck
@ -333,17 +306,8 @@ func TestBridge_UserAgentFromIMAPID(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, mocks *bridge.Mocks) {
imapWaiter := waitForIMAPServerReady(b)
defer imapWaiter.Done()
smtpWaiter := waitForSMTPServerReady(b)
defer smtpWaiter.Done()
require.NoError(t, getErr(b.LoginFull(ctx, otherUser, otherPassword, nil, nil))) require.NoError(t, getErr(b.LoginFull(ctx, otherUser, otherPassword, nil, nil)))
imapWaiter.Wait()
smtpWaiter.Wait()
imapClient, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, b.GetIMAPPort())) imapClient, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, b.GetIMAPPort()))
require.NoError(t, err) require.NoError(t, err)
defer func() { _ = imapClient.Logout() }() defer func() { _ = imapClient.Logout() }()
@ -715,21 +679,12 @@ func TestBridge_InitGluonDirectory(t *testing.T) {
func TestBridge_LoginFailed(t *testing.T) { func TestBridge_LoginFailed(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
imapWaiter := waitForIMAPServerReady(bridge)
defer imapWaiter.Done()
smtpWaiter := waitForSMTPServerReady(bridge)
defer smtpWaiter.Done()
failCh, done := chToType[events.Event, events.IMAPLoginFailed](bridge.GetEvents(events.IMAPLoginFailed{})) failCh, done := chToType[events.Event, events.IMAPLoginFailed](bridge.GetEvents(events.IMAPLoginFailed{}))
defer done() defer done()
_, err := bridge.LoginFull(ctx, username, password, nil, nil) _, err := bridge.LoginFull(ctx, username, password, nil, nil)
require.NoError(t, err) require.NoError(t, err)
imapWaiter.Wait()
smtpWaiter.Wait()
imapClient, err := eventuallyDial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetIMAPPort()))) imapClient, err := eventuallyDial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetIMAPPort())))
require.NoError(t, err) require.NoError(t, err)
@ -757,12 +712,6 @@ func TestBridge_ChangeCacheDirectory(t *testing.T) {
configDir, err := b.GetGluonDataDir() configDir, err := b.GetGluonDataDir()
require.NoError(t, err) require.NoError(t, err)
imapWaiter := waitForIMAPServerReady(b)
defer imapWaiter.Done()
smtpWaiter := waitForSMTPServerReady(b)
defer smtpWaiter.Done()
// Login the user. // Login the user.
syncCh, done := chToType[events.Event, events.SyncFinished](b.GetEvents(events.SyncFinished{})) syncCh, done := chToType[events.Event, events.SyncFinished](b.GetEvents(events.SyncFinished{}))
defer done() defer done()
@ -796,9 +745,6 @@ func TestBridge_ChangeCacheDirectory(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.True(t, info.State == bridge.Connected) require.True(t, info.State == bridge.Connected)
imapWaiter.Wait()
smtpWaiter.Wait()
client, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, b.GetIMAPPort())) client, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, b.GetIMAPPort()))
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, client.Login(info.Addresses[0], string(info.BridgePass))) require.NoError(t, client.Login(info.Addresses[0], string(info.BridgePass)))

View File

@ -64,9 +64,6 @@ func TestBridge_HandleDraftsSendFromOtherClient(t *testing.T) {
// The initial user should be fully synced. // The initial user should be fully synced.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(b *bridge.Bridge, _ *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(b *bridge.Bridge, _ *bridge.Mocks) {
waiter := waitForIMAPServerReady(b)
defer waiter.Done()
syncCh, done := chToType[events.Event, events.SyncFinished](b.GetEvents(events.SyncFinished{})) syncCh, done := chToType[events.Event, events.SyncFinished](b.GetEvents(events.SyncFinished{}))
defer done() defer done()
@ -74,7 +71,6 @@ func TestBridge_HandleDraftsSendFromOtherClient(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, userID, (<-syncCh).UserID) require.Equal(t, userID, (<-syncCh).UserID)
waiter.Wait()
info, err := b.GetUserInfo(userID) info, err := b.GetUserInfo(userID)
require.NoError(t, err) require.NoError(t, err)

View File

@ -46,17 +46,12 @@ func TestBridge_Send(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
smtpWaiter := waitForSMTPServerReady(bridge)
defer smtpWaiter.Done()
senderUserID, err := bridge.LoginFull(ctx, username, password, nil, nil) senderUserID, err := bridge.LoginFull(ctx, username, password, nil, nil)
require.NoError(t, err) require.NoError(t, err)
recipientUserID, err := bridge.LoginFull(ctx, "recipient", password, nil, nil) recipientUserID, err := bridge.LoginFull(ctx, "recipient", password, nil, nil)
require.NoError(t, err) require.NoError(t, err)
smtpWaiter.Wait()
senderInfo, err := bridge.GetUserInfo(senderUserID) senderInfo, err := bridge.GetUserInfo(senderUserID)
require.NoError(t, err) require.NoError(t, err)
@ -409,9 +404,6 @@ SGVsbG8gd29ybGQK
require.NoError(t, err) require.NoError(t, err)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
smtpWaiter := waitForSMTPServerReady(bridge)
defer smtpWaiter.Done()
senderUserID, err := bridge.LoginFull(ctx, username, password, nil, nil) senderUserID, err := bridge.LoginFull(ctx, username, password, nil, nil)
require.NoError(t, err) require.NoError(t, err)
@ -431,8 +423,6 @@ SGVsbG8gd29ybGQK
messageMultipartWithoutTextWithTextAttachment, messageMultipartWithoutTextWithTextAttachment,
} }
smtpWaiter.Wait()
for _, m := range messages { for _, m := range messages {
// Dial the server. // Dial the server.
client, err := smtp.Dial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetSMTPPort()))) client, err := smtp.Dial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetSMTPPort())))
@ -617,9 +607,6 @@ Hello world
require.NoError(t, err) require.NoError(t, err)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
smtpWaiter := waitForSMTPServerReady(bridge)
defer smtpWaiter.Done()
senderUserID, err := bridge.LoginFull(ctx, username, password, nil, nil) senderUserID, err := bridge.LoginFull(ctx, username, password, nil, nil)
require.NoError(t, err) require.NoError(t, err)
@ -639,8 +626,6 @@ Hello world
messageInlineImageFollowedByText, messageInlineImageFollowedByText,
} }
smtpWaiter.Wait()
for _, m := range messages { for _, m := range messages {
// Dial the server. // Dial the server.
client, err := smtp.Dial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetSMTPPort()))) client, err := smtp.Dial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetSMTPPort())))
@ -714,17 +699,12 @@ func TestBridge_SendAddressDisabled(t *testing.T) {
require.NoError(t, s.ChangeAddressAllowSend(senderUserID, addrID, false)) require.NoError(t, s.ChangeAddressAllowSend(senderUserID, addrID, false))
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
smtpWaiter := waitForSMTPServerReady(bridge)
defer smtpWaiter.Done()
senderUserID, err := bridge.LoginFull(ctx, "sender", password, nil, nil) senderUserID, err := bridge.LoginFull(ctx, "sender", password, nil, nil)
require.NoError(t, err) require.NoError(t, err)
_, err = bridge.LoginFull(ctx, "recipient", password, nil, nil) _, err = bridge.LoginFull(ctx, "recipient", password, nil, nil)
require.NoError(t, err) require.NoError(t, err)
smtpWaiter.Wait()
recipientInfo, err := bridge.GetUserInfo(recipientUserID) recipientInfo, err := bridge.GetUserInfo(recipientUserID)
require.NoError(t, err) require.NoError(t, err)
@ -750,7 +730,7 @@ func TestBridge_SendAddressDisabled(t *testing.T) {
strings.NewReader("Subject: Test 1\r\n\r\nHello world!"), strings.NewReader("Subject: Test 1\r\n\r\nHello world!"),
) )
smtpErr := smtpservice.NewErrCanNotSendOnAddress(senderInfo.Addresses[0]) smtpErr := smtpservice.NewErrCannotSendFromAddress(senderInfo.Addresses[0])
require.Equal(t, fmt.Sprintf("Error: %v", smtpErr.Error()), err.Error()) require.Equal(t, fmt.Sprintf("Error: %v", smtpErr.Error()), err.Error())
}) })
}) })

View File

@ -36,9 +36,6 @@ import (
func TestBridge_Report(t *testing.T) { func TestBridge_Report(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(b *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(b *bridge.Bridge, mocks *bridge.Mocks) {
imapWaiter := waitForIMAPServerReady(b)
defer imapWaiter.Done()
syncCh, done := chToType[events.Event, events.SyncFinished](b.GetEvents(events.SyncFinished{})) syncCh, done := chToType[events.Event, events.SyncFinished](b.GetEvents(events.SyncFinished{}))
defer done() defer done()
@ -54,8 +51,6 @@ func TestBridge_Report(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.True(t, info.State == bridge.Connected) require.True(t, info.State == bridge.Connected)
imapWaiter.Wait()
// Dial the IMAP port. // Dial the IMAP port.
conn, err := net.Dial("tcp", fmt.Sprintf("%v:%v", constants.Host, b.GetIMAPPort())) conn, err := net.Dial("tcp", fmt.Sprintf("%v:%v", constants.Host, b.GetIMAPPort()))
require.NoError(t, err) require.NoError(t, err)

View File

@ -20,6 +20,7 @@ package bridge_test
import ( import (
"context" "context"
"fmt" "fmt"
"net"
"testing" "testing"
"github.com/ProtonMail/go-proton-api" "github.com/ProtonMail/go-proton-api"
@ -27,57 +28,39 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/bridge" "github.com/ProtonMail/proton-bridge/v3/internal/bridge"
"github.com/ProtonMail/proton-bridge/v3/internal/constants" "github.com/ProtonMail/proton-bridge/v3/internal/constants"
"github.com/ProtonMail/proton-bridge/v3/internal/events" "github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/emersion/go-smtp"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestServerManager_NoLoadedUsersNoServers(t *testing.T) { func TestServerManager_ServersStartWithBridge(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
_, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, bridge.GetIMAPPort())) imapClient, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, bridge.GetIMAPPort()))
require.Error(t, err)
})
})
}
func TestServerManager_ServersStartAfterFirstConnectedUser(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
imapWaiter := waitForIMAPServerReady(bridge)
defer imapWaiter.Done()
smtpWaiter := waitForSMTPServerReady(bridge)
defer smtpWaiter.Done()
_, err := bridge.LoginFull(ctx, username, password, nil, nil)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, imapClient.Logout())
imapWaiter.Wait() smtpClient, err := smtp.Dial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetSMTPPort())))
smtpWaiter.Wait() require.NoError(t, err)
smtpClient.Close() //nolint:errcheck
}) })
}) })
} }
func TestServerManager_ServersStopsAfterUserLogsOut(t *testing.T) { func TestServerManager_ServersKeepsRunningfterUserLogsOut(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
imapWaiter := waitForIMAPServerReady(bridge)
defer imapWaiter.Done()
smtpWaiter := waitForSMTPServerReady(bridge)
defer smtpWaiter.Done()
userID, err := bridge.LoginFull(ctx, username, password, nil, nil) userID, err := bridge.LoginFull(ctx, username, password, nil, nil)
require.NoError(t, err) require.NoError(t, err)
imapWaiter.Wait()
smtpWaiter.Wait()
imapWaiterStopped := waitForIMAPServerStopped(bridge)
defer imapWaiterStopped.Done()
require.NoError(t, bridge.LogoutUser(ctx, userID)) require.NoError(t, bridge.LogoutUser(ctx, userID))
imapWaiterStopped.Wait() imapClient, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, bridge.GetIMAPPort()))
require.NoError(t, err)
require.NoError(t, imapClient.Logout())
smtpClient, err := smtp.Dial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetSMTPPort())))
require.NoError(t, err)
smtpClient.Close() //nolint:errcheck
}) })
}) })
} }
@ -90,21 +73,12 @@ func TestServerManager_ServersDoNotStopWhenThereIsStillOneActiveUser(t *testing.
require.NoError(t, err) require.NoError(t, err)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
imapWaiter := waitForIMAPServerReady(bridge)
defer imapWaiter.Done()
smtpWaiter := waitForSMTPServerReady(bridge)
defer smtpWaiter.Done()
_, err := bridge.LoginFull(ctx, username, password, nil, nil) _, err := bridge.LoginFull(ctx, username, password, nil, nil)
require.NoError(t, err) require.NoError(t, err)
userIDOther, err := bridge.LoginFull(ctx, otherUser, otherPassword, nil, nil) userIDOther, err := bridge.LoginFull(ctx, otherUser, otherPassword, nil, nil)
require.NoError(t, err) require.NoError(t, err)
imapWaiter.Wait()
smtpWaiter.Wait()
evtCh, cancel := bridge.GetEvents(events.UserDeauth{}) evtCh, cancel := bridge.GetEvents(events.UserDeauth{})
defer cancel() defer cancel()
@ -115,31 +89,10 @@ func TestServerManager_ServersDoNotStopWhenThereIsStillOneActiveUser(t *testing.
imapClient, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, bridge.GetIMAPPort())) imapClient, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, bridge.GetIMAPPort()))
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, imapClient.Logout()) require.NoError(t, imapClient.Logout())
})
})
}
func TestServerManager_ServersStartIfAtLeastOneUserIsLoggedIn(t *testing.T) { smtpClient, err := smtp.Dial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetSMTPPort())))
otherPassword := []byte("bar")
otherUser := "foo"
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
userIDOther, _, err := s.CreateUser(otherUser, otherPassword)
require.NoError(t, err)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
_, err := bridge.LoginFull(ctx, username, password, nil, nil)
require.NoError(t, err) require.NoError(t, err)
smtpClient.Close() //nolint:errcheck
_, err = bridge.LoginFull(ctx, otherUser, otherPassword, nil, nil)
require.NoError(t, err)
})
require.NoError(t, s.RevokeUser(userIDOther))
withBridgeWaitForServers(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
imapClient, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, bridge.GetIMAPPort()))
require.NoError(t, err)
require.NoError(t, imapClient.Logout())
}) })
}) })
} }
@ -162,8 +115,13 @@ func TestServerManager_NetworkLossStopsServers(t *testing.T) {
_, err := bridge.LoginFull(ctx, username, password, nil, nil) _, err := bridge.LoginFull(ctx, username, password, nil, nil)
require.NoError(t, err) require.NoError(t, err)
imapWaiter.Wait() imapClient, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, bridge.GetIMAPPort()))
smtpWaiter.Wait() require.NoError(t, err)
require.NoError(t, imapClient.Logout())
smtpClient, err := smtp.Dial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetSMTPPort())))
require.NoError(t, err)
smtpClient.Close() //nolint:errcheck
netCtl.Disable() netCtl.Disable()

View File

@ -28,6 +28,7 @@ import (
"github.com/ProtonMail/gluon/reporter" "github.com/ProtonMail/gluon/reporter"
"github.com/ProtonMail/go-proton-api" "github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal/events" "github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/hv"
"github.com/ProtonMail/proton-bridge/v3/internal/logging" "github.com/ProtonMail/proton-bridge/v3/internal/logging"
"github.com/ProtonMail/proton-bridge/v3/internal/safe" "github.com/ProtonMail/proton-bridge/v3/internal/safe"
"github.com/ProtonMail/proton-bridge/v3/internal/services/imapservice" "github.com/ProtonMail/proton-bridge/v3/internal/services/imapservice"
@ -123,15 +124,20 @@ func (bridge *Bridge) QueryUserInfo(query string) (UserInfo, error) {
} }
// LoginAuth begins the login process. It returns an authorized client that might need 2FA. // LoginAuth begins the login process. It returns an authorized client that might need 2FA.
func (bridge *Bridge) LoginAuth(ctx context.Context, username string, password []byte) (*proton.Client, proton.Auth, error) { func (bridge *Bridge) LoginAuth(ctx context.Context, username string, password []byte, hvDetails *proton.APIHVDetails) (*proton.Client, proton.Auth, error) {
logUser.WithField("username", logging.Sensitive(username)).Info("Authorizing user for login") logUser.WithField("username", logging.Sensitive(username)).Info("Authorizing user for login")
if username == "crash@bandicoot" { if username == "crash@bandicoot" {
panic("Your wish is my command.. I crash!") panic("Your wish is my command.. I crash!")
} }
client, auth, err := bridge.api.NewClientWithLoginWithHVToken(ctx, username, password, hvDetails)
client, auth, err := bridge.api.NewClientWithLogin(ctx, username, password)
if err != nil { if err != nil {
if hv.IsHvRequest(err) {
logUser.WithFields(logrus.Fields{"username": logging.Sensitive(username),
"loginError": err.Error()}).Info("Human Verification requested for login")
return nil, proton.Auth{}, err
}
return nil, proton.Auth{}, fmt.Errorf("failed to create new API client: %w", err) return nil, proton.Auth{}, fmt.Errorf("failed to create new API client: %w", err)
} }
@ -154,12 +160,13 @@ func (bridge *Bridge) LoginUser(
client *proton.Client, client *proton.Client,
auth proton.Auth, auth proton.Auth,
keyPass []byte, keyPass []byte,
hvDetails *proton.APIHVDetails,
) (string, error) { ) (string, error) {
logUser.WithField("userID", auth.UserID).Info("Logging in authorized user") logUser.WithField("userID", auth.UserID).Info("Logging in authorized user")
userID, err := try.CatchVal( userID, err := try.CatchVal(
func() (string, error) { func() (string, error) {
return bridge.loginUser(ctx, client, auth.UID, auth.RefreshToken, keyPass) return bridge.loginUser(ctx, client, auth.UID, auth.RefreshToken, keyPass, hvDetails)
}, },
) )
@ -192,7 +199,8 @@ func (bridge *Bridge) LoginFull(
) (string, error) { ) (string, error) {
logUser.WithField("username", logging.Sensitive(username)).Info("Performing full user login") logUser.WithField("username", logging.Sensitive(username)).Info("Performing full user login")
client, auth, err := bridge.LoginAuth(ctx, username, password) // (atanas) the following may need to be modified once HV is merged (its used only for testing; and depends on whether we will test HV related logic)
client, auth, err := bridge.LoginAuth(ctx, username, password, nil)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to begin login process: %w", err) return "", fmt.Errorf("failed to begin login process: %w", err)
} }
@ -225,7 +233,7 @@ func (bridge *Bridge) LoginFull(
keyPass = password keyPass = password
} }
userID, err := bridge.LoginUser(ctx, client, auth, keyPass) userID, err := bridge.LoginUser(ctx, client, auth, keyPass, nil)
if err != nil { if err != nil {
if deleteErr := client.AuthDelete(ctx); deleteErr != nil { if deleteErr := client.AuthDelete(ctx); deleteErr != nil {
logUser.WithError(err).Error("Failed to delete auth") logUser.WithError(err).Error("Failed to delete auth")
@ -374,8 +382,8 @@ func (bridge *Bridge) SendBadEventUserFeedback(_ context.Context, userID string,
}, bridge.usersLock) }, bridge.usersLock)
} }
func (bridge *Bridge) loginUser(ctx context.Context, client *proton.Client, authUID, authRef string, keyPass []byte) (string, error) { func (bridge *Bridge) loginUser(ctx context.Context, client *proton.Client, authUID, authRef string, keyPass []byte, hvDetails *proton.APIHVDetails) (string, error) {
apiUser, err := client.GetUser(ctx) apiUser, err := client.GetUserWithHV(ctx, hvDetails)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to get API user: %w", err) return "", fmt.Errorf("failed to get API user: %w", err)
} }

View File

@ -139,9 +139,6 @@ func test_badMessage_badEvent(userFeedback func(t *testing.T, ctx context.Contex
}) })
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
smtpWaiter := waitForSMTPServerReady(bridge)
defer smtpWaiter.Done()
userLoginAndSync(ctx, t, bridge, "user", password) userLoginAndSync(ctx, t, bridge, "user", password)
var messageIDs []string var messageIDs []string
@ -177,8 +174,6 @@ func test_badMessage_badEvent(userFeedback func(t *testing.T, ctx context.Contex
userFeedback(t, ctx, bridge, badUserID) userFeedback(t, ctx, bridge, badUserID)
smtpWaiter.Wait()
userContinueEventProcess(ctx, t, s, bridge) userContinueEventProcess(ctx, t, s, bridge)
}) })
}) })
@ -197,9 +192,6 @@ func TestBridge_User_BadMessage_NoBadEvent(t *testing.T) {
}) })
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
smtpWaiter := waitForSMTPServerReady(bridge)
defer smtpWaiter.Done()
userLoginAndSync(ctx, t, bridge, "user", password) userLoginAndSync(ctx, t, bridge, "user", password)
var messageIDs []string var messageIDs []string
@ -223,7 +215,6 @@ func TestBridge_User_BadMessage_NoBadEvent(t *testing.T) {
require.NoError(t, c.DeleteMessage(ctx, messageIDs...)) require.NoError(t, c.DeleteMessage(ctx, messageIDs...))
}) })
smtpWaiter.Wait()
userContinueEventProcess(ctx, t, s, bridge) userContinueEventProcess(ctx, t, s, bridge)
}) })
}) })
@ -776,20 +767,11 @@ func TestBridge_User_CreateDisabledAddress(t *testing.T) {
func TestBridge_User_HandleParentLabelRename(t *testing.T) { func TestBridge_User_HandleParentLabelRename(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
imapWaiter := waitForIMAPServerReady(bridge)
defer imapWaiter.Done()
smtpWaiter := waitForSMTPServerReady(bridge)
defer smtpWaiter.Done()
require.NoError(t, getErr(bridge.LoginFull(ctx, username, password, nil, nil))) require.NoError(t, getErr(bridge.LoginFull(ctx, username, password, nil, nil)))
info, err := bridge.QueryUserInfo(username) info, err := bridge.QueryUserInfo(username)
require.NoError(t, err) require.NoError(t, err)
imapWaiter.Wait()
smtpWaiter.Wait()
cli, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, bridge.GetIMAPPort())) cli, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, bridge.GetIMAPPort()))
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, cli.Login(info.Addresses[0], string(info.BridgePass))) require.NoError(t, cli.Login(info.Addresses[0], string(info.BridgePass)))

View File

@ -64,7 +64,8 @@ func TestTLSPinInvalid(t *testing.T) {
checkTLSIssueHandler(t, 1, called) checkTLSIssueHandler(t, 1, called)
} }
func TestTLSPinNoMatch(t *testing.T) { // Disabled for now we'll need to patch this up.
func _TestTLSPinNoMatch(t *testing.T) { //nolint:unused
skipIfProxyIsSet(t) skipIfProxyIsSet(t)
called, _, reporter, checker, cm := createClientWithPinningDialer(getRootURL()) called, _, reporter, checker, cm := createClientWithPinningDialer(getRootURL())

View File

@ -29,13 +29,11 @@ using namespace bridgepp;
namespace { namespace {
QString const defaultKeychain = "defaultKeychain"; ///< The default keychain. QString const defaultKeychain = "defaultKeychain"; ///< The default keychain.
QString const HV_ERROR_TEMPLATE = "failed to create new API client: 422 POST https://mail-api.proton.me/auth/v4: CAPTCHA validation failed (Code=12087, Status=422)";
} }
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
// //
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
@ -349,6 +347,7 @@ Status GRPCService::ForceLauncher(ServerContext *, StringValue const *request, E
/// \return The status for the call. /// \return The status for the call.
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
Status GRPCService::SetMainExecutable(ServerContext *, StringValue const *request, Empty *) { Status GRPCService::SetMainExecutable(ServerContext *, StringValue const *request, Empty *) {
resetHv();
app().log().debug(__FUNCTION__); app().log().debug(__FUNCTION__);
app().log().info(QString("SetMainExecutable: %1").arg(QString::fromStdString(request->value()))); app().log().info(QString("SetMainExecutable: %1").arg(QString::fromStdString(request->value())));
return Status::OK; return Status::OK;
@ -418,7 +417,19 @@ Status GRPCService::Login(ServerContext *, LoginRequest const *request, Empty *)
return Status::OK; return Status::OK;
} }
if (usersTab.nextUserHvRequired() && !hvWasRequested_ && previousHvUsername_ != QString::fromStdString(request->username())) {
hvWasRequested_ = true;
previousHvUsername_ = QString::fromStdString(request->username());
qtProxy_.sendDelayedEvent(newLoginHvRequestedEvent());
return Status::OK;
} else {
hvWasRequested_ = false;
previousHvUsername_ = "";
}
if (usersTab.nextUserHvError()) {
qtProxy_.sendDelayedEvent(newLoginError(LoginErrorType::HV_ERROR, HV_ERROR_TEMPLATE));
return Status::OK;
}
if (usersTab.nextUserUsernamePasswordError()) { if (usersTab.nextUserUsernamePasswordError()) {
qtProxy_.sendDelayedEvent(newLoginError(LoginErrorType::USERNAME_PASSWORD_ERROR, usersTab.usernamePasswordErrorMessage())); qtProxy_.sendDelayedEvent(newLoginError(LoginErrorType::USERNAME_PASSWORD_ERROR, usersTab.usernamePasswordErrorMessage()));
return Status::OK; return Status::OK;
@ -495,6 +506,7 @@ Status GRPCService::Login2Passwords(ServerContext *, LoginRequest const *request
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
Status GRPCService::LoginAbort(ServerContext *, LoginAbortRequest const *request, Empty *) { Status GRPCService::LoginAbort(ServerContext *, LoginAbortRequest const *request, Empty *) {
app().log().debug(__FUNCTION__); app().log().debug(__FUNCTION__);
this->resetHv();
loginUsername_ = QString(); loginUsername_ = QString();
return Status::OK; return Status::OK;
} }
@ -953,3 +965,11 @@ void GRPCService::finishLogin() {
qtProxy_.sendDelayedEvent(newLoginFinishedEvent(user->id(), alreadyExist)); qtProxy_.sendDelayedEvent(newLoginFinishedEvent(user->id(), alreadyExist));
} }
//****************************************************************************************************************************************************
//
//****************************************************************************************************************************************************
void GRPCService::resetHv() {
hvWasRequested_ = false;
previousHvUsername_ = "";
}

View File

@ -106,6 +106,7 @@ public: // member functions.
private: // member functions private: // member functions
void finishLogin(); ///< finish the login procedure once the credentials have been validated. void finishLogin(); ///< finish the login procedure once the credentials have been validated.
void resetHv(); ///< Resets the human verification state.
private: // data member private: // data member
mutable QMutex eventStreamMutex_; ///< Mutex used to access eventQueue_, isStreaming_ and shouldStopStreaming_; mutable QMutex eventStreamMutex_; ///< Mutex used to access eventQueue_, isStreaming_ and shouldStopStreaming_;
@ -113,6 +114,8 @@ private: // data member
bool isStreaming_; ///< Is the gRPC stream running. Access protected by eventStreamMutex_; bool isStreaming_; ///< Is the gRPC stream running. Access protected by eventStreamMutex_;
bool eventStreamShouldStop_; ///< Should the stream be stopped? Access protected by eventStreamMutex bool eventStreamShouldStop_; ///< Should the stream be stopped? Access protected by eventStreamMutex
QString loginUsername_; ///< The username used for the current login procedure. QString loginUsername_; ///< The username used for the current login procedure.
QString previousHvUsername_; ///< The previous username used for HV.
bool hvWasRequested_ {false}; ///< Was human verification requested.
GRPCQtProxy qtProxy_; ///< Qt Proxy used to send signals, as this class is not a QObject. GRPCQtProxy qtProxy_; ///< Qt Proxy used to send signals, as this class is not a QObject.
}; };

View File

@ -277,6 +277,22 @@ bridgepp::SPUser UsersTab::userWithUsernameOrEmail(QString const &username) {
} }
//****************************************************************************************************************************************************
/// \return true if the next login attempt should trigger a human verification request
//****************************************************************************************************************************************************
bool UsersTab::nextUserHvRequired() const {
return ui_.checkHV3Required->isChecked();
}
//****************************************************************************************************************************************************
/// \return true if the next login attempt should trigger a human verification error
//****************************************************************************************************************************************************
bool UsersTab::nextUserHvError() const {
return ui_.checkHV3Error->isChecked();
}
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
/// \return true iff the next login attempt should trigger a username/password error. /// \return true iff the next login attempt should trigger a username/password error.
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************

View File

@ -39,6 +39,8 @@ public: // member functions.
UserTable &userTable(); ///< Returns a reference to the user table. UserTable &userTable(); ///< Returns a reference to the user table.
bridgepp::SPUser userWithID(QString const &userID); ///< Get the user with the given ID. bridgepp::SPUser userWithID(QString const &userID); ///< Get the user with the given ID.
bridgepp::SPUser userWithUsernameOrEmail(QString const &username); ///< Get the user with the given username. bridgepp::SPUser userWithUsernameOrEmail(QString const &username); ///< Get the user with the given username.
bool nextUserHvRequired() const; ///< Check if next user login should trigger HV
bool nextUserHvError() const; ///< Check if next user login should trigger HV error
bool nextUserUsernamePasswordError() const; ///< Check if next user login should trigger a username/password error. bool nextUserUsernamePasswordError() const; ///< Check if next user login should trigger a username/password error.
bool nextUserFreeUserError() const; ///< Check if next user login should trigger a Free user error. bool nextUserFreeUserError() const; ///< Check if next user login should trigger a Free user error.
bool nextUserTFARequired() const; ///< Check if next user login should requires 2FA. bool nextUserTFARequired() const; ///< Check if next user login should requires 2FA.

View File

@ -290,6 +290,20 @@
</item> </item>
</layout> </layout>
</item> </item>
<item>
<widget class="QCheckBox" name="checkHV3Required">
<property name="text">
<string>HV3 required</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="checkHV3Error">
<property name="text">
<string>HV3 error</string>
</property>
</widget>
</item>
<item> <item>
<widget class="QCheckBox" name="checkFreeUserError"> <widget class="QCheckBox" name="checkFreeUserError">
<property name="text"> <property name="text">

View File

@ -140,7 +140,7 @@ if (WIN32) # on Windows, we add a (non-Qt) resource file that contains the appli
endif() endif()
target_precompile_headers(bridge-gui PRIVATE Pch.h) target_precompile_headers(bridge-gui PRIVATE Pch.h)
target_include_directories(bridge-gui PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${SENTRY_CONFIG_GENERATED_FILE_DIR}) target_include_directories(bridge-gui PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}" ${SENTRY_CONFIG_GENERATED_FILE_DIR})
target_link_libraries(bridge-gui target_link_libraries(bridge-gui
Qt6::Widgets Qt6::Widgets
Qt6::Core Qt6::Core

View File

@ -15,113 +15,85 @@
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>. // along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
#include "Pch.h" #include "Pch.h"
#include "CommandLine.h" #include "CommandLine.h"
#include "Settings.h" #include "Settings.h"
#include <bridgepp/CLI/CLIUtils.h>
#include <bridgepp/SessionID/SessionID.h> #include <bridgepp/SessionID/SessionID.h>
using namespace bridgepp; using namespace bridgepp;
namespace { namespace {
QString const hyphenatedLauncherFlag = "--launcher"; ///< launcher flag parameter used for bridge.
QString const launcherFlag = "--launcher"; ///< launcher flag parameter used for bridge. QString const hyphenatedWindowFlag = "--no-window"; ///< The no-window command-line flag.
QString const noWindowFlag = "--no-window"; ///< The no-window command-line flag. QString const hyphenatedSoftwareRendererFlag = "--software-renderer"; ///< The 'software-renderer' command-line flag. enable software rendering for a single execution
QString const softwareRendererFlag = "--software-renderer"; ///< The 'software-renderer' command-line flag. enable software rendering for a single execution QString const hyphenatedSetSoftwareRendererFlag = "--set-software-renderer"; ///< The 'set-software-renderer' command-line flag. Software rendering will be used for all subsequent executions of the application.
QString const setSoftwareRendererFlag = "--set-software-renderer"; ///< The 'set-software-renderer' command-line flag. Software rendering will be used for all subsequent executions of the application. QString const hyphenatedSetHardwareRendererFlag = "--set-hardware-renderer"; ///< The 'set-hardware-renderer' command-line flag. Hardware rendering will be used for all subsequent executions of the application.
QString const setHardwareRendererFlag = "--set-hardware-renderer"; ///< The 'set-hardware-renderer' command-line flag. Hardware rendering will be used for all subsequent executions of the application.
//****************************************************************************************************************************************************
/// \brief parse a command-line string argument as expected by go's CLI package.
/// \param[in] argc The number of arguments passed to the application.
/// \param[in] argv The list of arguments passed to the application.
/// \param[in] paramNames the list of names for the parameter
//****************************************************************************************************************************************************
QString parseGoCLIStringArgument(int argc, char *argv[], QStringList paramNames) {
// go cli package is pretty permissive when it comes to parsing arguments. For each name 'param', all the following seems to be accepted:
// -param value
// --param value
// -param=value
// --param=value
for (QString const &paramName: paramNames) {
for (qsizetype i = 1; i < argc; ++i) {
QString const arg(QString::fromLocal8Bit(argv[i]));
if ((i < argc - 1) && ((arg == "-" + paramName) || (arg == "--" + paramName))) {
return QString(argv[i + 1]);
}
QRegularExpressionMatch match = QRegularExpression(QString("^-{1,2}%1=(.+)$").arg(paramName)).match(arg);
if (match.hasMatch()) {
return match.captured(1);
}
}
}
return QString();
}
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
/// \brief Parse the log level from the command-line arguments. /// \brief Parse the log level from the command-line arguments.
/// ///
/// \param[in] argc The number of arguments passed to the application. /// \param[in] args The command-line arguments.
/// \param[in] argv The list of arguments passed to the application.
/// \return The log level. if not specified on the command-line, the default log level is returned. /// \return The log level. if not specified on the command-line, the default log level is returned.
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
Log::Level parseLogLevel(int argc, char *argv[]) { Log::Level parseLogLevel(QStringList const &args) {
QString levelStr = parseGoCLIStringArgument(argc, argv, { "l", "log-level" }); QStringList levelStr = parseGoCLIStringArgument(args, {"l", "log-level"});
if (levelStr.isEmpty()) { if (levelStr.isEmpty()) {
return Log::defaultLevel; return Log::defaultLevel;
} }
Log::Level level = Log::defaultLevel; Log::Level level = Log::defaultLevel;
Log::stringToLevel(levelStr, level); Log::stringToLevel(levelStr.back(), level);
return level; return level;
} }
} // anonymous namespace } // anonymous namespace
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
/// \param[in] argc number of arguments passed to the application. /// \param[in] argv list of arguments passed to the application, including the exe name/path at index 0.
/// \param[in] argv list of arguments passed to the application.
/// \return The parsed options. /// \return The parsed options.
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
CommandLineOptions parseCommandLine(int argc, char *argv[]) { CommandLineOptions parseCommandLine(QStringList const &argv) {
CommandLineOptions options; CommandLineOptions options;
bool flagFound = false; bool launcherFlagFound = false;
options.launcher = QString::fromLocal8Bit(argv[0]); options.launcher = argv[0];
// for unknown reasons, on Windows QCoreApplication::arguments() frequently returns an empty list, which is incorrect, so we rebuild the argument // for unknown reasons, on Windows QCoreApplication::arguments() frequently returns an empty list, which is incorrect, so we rebuild the argument
// list from the original argc and argv values. // list from the original argc and argv values.
for (int i = 1; i < argc; i++) { for (int i = 1; i < argv.count(); i++) {
QString const &arg = QString::fromLocal8Bit(argv[i]); QString const &arg = argv[i];
// we can't use QCommandLineParser here since it will fail on unknown options. // we can't use QCommandLineParser here since it will fail on unknown options.
// we skip session-id for now we'll process it later, with a special treatment for duplicates
if (arg == hyphenatedSessionIDFlag) {
i++; // we skip the next param, which if the flag's value.
continue;
}
if (arg.startsWith(hyphenatedSessionIDFlag + "=")) {
continue;
}
// Arguments may contain some bridge flags. // Arguments may contain some bridge flags.
if (arg == softwareRendererFlag) { if (arg == hyphenatedSoftwareRendererFlag) {
options.bridgeGuiArgs.append(arg); options.bridgeGuiArgs.append(arg);
options.useSoftwareRenderer = true; options.useSoftwareRenderer = true;
} }
if (arg == setSoftwareRendererFlag) { if (arg == hyphenatedSetSoftwareRendererFlag) {
app().settings().setUseSoftwareRenderer(true); app().settings().setUseSoftwareRenderer(true);
continue; // setting is permanent. no need to keep/pass it to bridge for restart. continue; // setting is permanent. no need to keep/pass it to bridge for restart.
} }
if (arg == setHardwareRendererFlag) { if (arg == hyphenatedSetHardwareRendererFlag) {
app().settings().setUseSoftwareRenderer(false); app().settings().setUseSoftwareRenderer(false);
continue; // setting is permanent. no need to keep/pass it to bridge for restart. continue; // setting is permanent. no need to keep/pass it to bridge for restart.
} }
if (arg == noWindowFlag) { if (arg == hyphenatedWindowFlag) {
options.noWindow = true; options.noWindow = true;
} }
if (arg == launcherFlag) { if (arg == hyphenatedLauncherFlag) {
options.bridgeArgs.append(arg); options.bridgeArgs.append(arg);
options.launcher = QString::fromLocal8Bit(argv[++i]); options.launcher = argv[++i];
options.bridgeArgs.append(options.launcher); options.bridgeArgs.append(options.launcher);
flagFound = true; launcherFlagFound = true;
} }
#ifdef QT_DEBUG #ifdef QT_DEBUG
else if (arg == "--attach" || arg == "-a") { else if (arg == "--attach" || arg == "-a") {
@ -135,22 +107,24 @@ CommandLineOptions parseCommandLine(int argc, char *argv[]) {
options.bridgeGuiArgs.append(arg); options.bridgeGuiArgs.append(arg);
} }
} }
if (!flagFound) { if (!launcherFlagFound) {
// add bridge-gui as launcher // add bridge-gui as launcher
options.bridgeArgs.append(launcherFlag); options.bridgeArgs.append(hyphenatedLauncherFlag);
options.bridgeArgs.append(options.launcher); options.bridgeArgs.append(options.launcher);
} }
options.logLevel = parseLogLevel(argc, argv); QStringList args;
if (!argv.isEmpty()) {
QString sessionID = parseGoCLIStringArgument(argc, argv, { "session-id" }); args = argv.last(argv.count() - 1);
if (sessionID.isEmpty()) {
// The session ID was not passed to us on the command-line -> create one and add to the command-line for bridge
sessionID = newSessionID();
options.bridgeArgs.append("--session-id");
options.bridgeArgs.append(sessionID);
} }
options.logLevel = parseLogLevel(args);
QString const sessionID = mostRecentSessionID(args);
options.bridgeArgs.append(hyphenatedSessionIDFlag);
options.bridgeArgs.append(sessionID);
app().setSessionID(sessionID); app().setSessionID(sessionID);
return options; return options;
} }

View File

@ -37,7 +37,7 @@ struct CommandLineOptions {
}; };
CommandLineOptions parseCommandLine(int argc, char *argv[]); ///< Parse the command-line arguments CommandLineOptions parseCommandLine(QStringList const &argv); ///< Parse the command-line arguments
#endif //BRIDGE_GUI_COMMAND_LINE_H #endif //BRIDGE_GUI_COMMAND_LINE_H

View File

@ -31,7 +31,7 @@ macro( AppendLib LIB_NAME HINT_PATH)
if( ${PATH_${UP_NAME}} STREQUAL "PATH_${UP_NAME}-NOTFOUND") if( ${PATH_${UP_NAME}} STREQUAL "PATH_${UP_NAME}-NOTFOUND")
message(SEND_ERROR "${LIB_NAME} was not found in ${HINT_PATH}") message(SEND_ERROR "${LIB_NAME} was not found in ${HINT_PATH}")
else() else()
list(APPEND DEPLOY_LIBS ${PATH_${UP_NAME}}) list(APPEND DEPLOY_LIBS "${PATH_${UP_NAME}}")
endif() endif()
endmacro() endmacro()

View File

@ -810,6 +810,18 @@ void QMLBackend::login(QString const &username, QString const &password) const {
) )
} }
void QMLBackend::loginHv(QString const &username, QString const &password) const {
HANDLE_EXCEPTION(
if (username.compare("coco@bandicoot", Qt::CaseInsensitive) == 0) {
throw Exception("User requested bridge-gui to crash by trying to log as coco@bandicoot",
"This error exists for test purposes and should be ignored.", __func__, tailOfLatestBridgeLog(app().sessionID()));
}
app().grpc().loginHv(username, password);
)
}
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
/// \param[in] username The username. /// \param[in] username The username.
@ -1334,6 +1346,8 @@ void QMLBackend::connectGrpcEvents() {
connect(client, &GRPCClient::login2PasswordErrorAbort, this, &QMLBackend::login2PasswordErrorAbort); connect(client, &GRPCClient::login2PasswordErrorAbort, this, &QMLBackend::login2PasswordErrorAbort);
connect(client, &GRPCClient::loginFinished, this, &QMLBackend::onLoginFinished); connect(client, &GRPCClient::loginFinished, this, &QMLBackend::onLoginFinished);
connect(client, &GRPCClient::loginAlreadyLoggedIn, this, &QMLBackend::onLoginAlreadyLoggedIn); connect(client, &GRPCClient::loginAlreadyLoggedIn, this, &QMLBackend::onLoginAlreadyLoggedIn);
connect(client, &GRPCClient::loginHvRequested, this, &QMLBackend::loginHvRequested);
connect(client, &GRPCClient::loginHvError, this, &QMLBackend::loginHvError);
// update events // update events
connect(client, &GRPCClient::updateManualError, this, &QMLBackend::updateManualError); connect(client, &GRPCClient::updateManualError, this, &QMLBackend::updateManualError);

View File

@ -183,6 +183,7 @@ public slots: // slot for signals received from QML -> To be forwarded to Bridge
void changeColorScheme(QString const &scheme); ///< Slot for the change of the theme. void changeColorScheme(QString const &scheme); ///< Slot for the change of the theme.
void setDiskCachePath(QUrl const &path) const; ///< Slot for the change of the disk cache path. void setDiskCachePath(QUrl const &path) const; ///< Slot for the change of the disk cache path.
void login(QString const &username, QString const &password) const; ///< Slot for the login button (initial login). void login(QString const &username, QString const &password) const; ///< Slot for the login button (initial login).
void loginHv(QString const &username, QString const &password) const; ///< Slot for the login button (after HV challenge completed).
void login2FA(QString const &username, QString const &code) const; ///< Slot for the login button (2FA login). void login2FA(QString const &username, QString const &code) const; ///< Slot for the login button (2FA login).
void login2Password(QString const &username, QString const &password) const; ///< Slot for the login button (mailbox password login). void login2Password(QString const &username, QString const &password) const; ///< Slot for the login button (mailbox password login).
void loginAbort(QString const &username) const; ///< Slot for the login abort procedure. void loginAbort(QString const &username) const; ///< Slot for the login abort procedure.
@ -238,6 +239,8 @@ signals: // Signals received from the Go backend, to be forwarded to QML
void login2PasswordErrorAbort(QString const &errorMsg); ///< Signal for the 'login2PasswordErrorAbort' gRPC stream event. void login2PasswordErrorAbort(QString const &errorMsg); ///< Signal for the 'login2PasswordErrorAbort' gRPC stream event.
void loginFinished(int index, bool wasSignedOut); ///< Signal for the 'loginFinished' gRPC stream event. void loginFinished(int index, bool wasSignedOut); ///< Signal for the 'loginFinished' gRPC stream event.
void loginAlreadyLoggedIn(int index); ///< Signal for the 'loginAlreadyLoggedIn' gRPC stream event. void loginAlreadyLoggedIn(int index); ///< Signal for the 'loginAlreadyLoggedIn' gRPC stream event.
void loginHvRequested(QString const &hvUrl); ///< Signal for the 'loginHvRequested' gRPC stream event.
void loginHvError(QString const &errorMsg); ///< Signal for the 'loginHvError' gRPC stream event.
void updateManualReady(QString const &version); ///< Signal for the 'updateManualReady' gRPC stream event. void updateManualReady(QString const &version); ///< Signal for the 'updateManualReady' gRPC stream event.
void updateManualRestartNeeded(); ///< Signal for the 'updateManualRestartNeeded' gRPC stream event. void updateManualRestartNeeded(); ///< Signal for the 'updateManualRestartNeeded' gRPC stream event.
void updateManualError(); ///< Signal for the 'updateManualError' gRPC stream event. void updateManualError(); ///< Signal for the 'updateManualError' gRPC stream event.

View File

@ -117,6 +117,7 @@
<file>qml/Resources/Help/WhyProfileWarning.html</file> <file>qml/Resources/Help/WhyProfileWarning.html</file>
<file>qml/SettingsItem.qml</file> <file>qml/SettingsItem.qml</file>
<file>qml/SettingsView.qml</file> <file>qml/SettingsView.qml</file>
<file>qml/SetupWizard/ClientConfigCertInstall.qml</file>
<file>qml/SetupWizard/ClientListItem.qml</file> <file>qml/SetupWizard/ClientListItem.qml</file>
<file>qml/SetupWizard/LeftPane.qml</file> <file>qml/SetupWizard/LeftPane.qml</file>
<file>qml/SetupWizard/ClientConfigAppleMail.qml</file> <file>qml/SetupWizard/ClientConfigAppleMail.qml</file>

View File

@ -182,6 +182,7 @@ TrayIcon::TrayIcon()
, notificationErrorIcon_(loadIconFromSVG(":/qml/icons/ic-alert.svg")) { , notificationErrorIcon_(loadIconFromSVG(":/qml/icons/ic-alert.svg")) {
this->generateDotIcons(); this->generateDotIcons();
this->setContextMenu(menu_.get()); this->setContextMenu(menu_.get());
this->setToolTip(PROJECT_FULL_NAME);
connect(menu_.get(), &QMenu::aboutToShow, this, &TrayIcon::onMenuAboutToShow); connect(menu_.get(), &QMenu::aboutToShow, this, &TrayIcon::onMenuAboutToShow);
connect(this, &TrayIcon::selectUser, &app().backend(), [](QString const& userID, bool forceShowWindow) { connect(this, &TrayIcon::selectUser, &app().backend(), [](QString const& userID, bool forceShowWindow) {

View File

@ -102,6 +102,7 @@ git submodule update --init --recursive $vcpkgRoot
-S . -B $buildDir -S . -B $buildDir
check_exit "CMake failed" check_exit "CMake failed"
. $cmakeExe --build $buildDir --config "$buildConfig" . $cmakeExe --build $buildDir --config "$buildConfig"
check_exit "Build failed" check_exit "Build failed"
@ -109,7 +110,7 @@ if ($($args.count) -gt 0 )
{ {
if ($args[0] = "install") if ($args[0] = "install")
{ {
. $cmakeExe --install $buildDir . $cmakeExe --install "$buildDir" -v
check_exit "Install failed" check_exit "Install failed"
} }
} }

View File

@ -15,7 +15,6 @@
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>. // along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
#include "BridgeApp.h" #include "BridgeApp.h"
#include "BuildConfig.h" #include "BuildConfig.h"
#include "CommandLine.h" #include "CommandLine.h"
@ -30,13 +29,12 @@
#include <bridgepp/Log/LogUtils.h> #include <bridgepp/Log/LogUtils.h>
#include <bridgepp/ProcessMonitor.h> #include <bridgepp/ProcessMonitor.h>
#include "bridgepp/CLI/CLIUtils.h"
#ifdef Q_OS_MACOS #ifdef Q_OS_MACOS
#include "MacOS/SecondInstance.h" #include "MacOS/SecondInstance.h"
#endif #endif
using namespace bridgepp; using namespace bridgepp;
@ -50,17 +48,14 @@ QString const exeSuffix = ".exe";
QString const exeSuffix; QString const exeSuffix;
#endif #endif
QString const bridgeLock = "bridge-v3.lock"; ///< The file name used for the bridge-gui lock file. QString const bridgeLock = "bridge-v3.lock"; ///< The file name used for the bridge-gui lock file.
QString const bridgeGUILock = "bridge-v3-gui.lock"; ///< The file name used for the bridge-gui lock file. QString const bridgeGUILock = "bridge-v3-gui.lock"; ///< The file name used for the bridge-gui lock file.
QString const exeName = "bridge" + exeSuffix; ///< The bridge executable file name.* QString const exeName = "bridge" + exeSuffix; ///< The bridge executable file name.*
qint64 const grpcServiceConfigWaitDelayMs = 180000; ///< The wait delay for the gRPC config file in milliseconds. qint64 constexpr grpcServiceConfigWaitDelayMs = 180000; ///< The wait delay for the gRPC config file in milliseconds.
QString const waitFlag = "--wait"; ///< The wait command-line flag. QString const waitFlag = "--wait"; ///< The wait command-line flag.
} // anonymous namespace } // anonymous namespace
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
/// \return The path of the bridge executable. /// \return The path of the bridge executable.
/// \return A null string if the executable could not be located. /// \return A null string if the executable could not be located.
@ -70,7 +65,6 @@ QString locateBridgeExe() {
return (fileInfo.exists() && fileInfo.isFile() && fileInfo.isExecutable()) ? fileInfo.absoluteFilePath() : QString(); return (fileInfo.exists() && fileInfo.isFile() && fileInfo.isExecutable()) ? fileInfo.absoluteFilePath() : QString();
} }
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
/// // initialize the Qt application. /// // initialize the Qt application.
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
@ -97,8 +91,6 @@ void initQtApplication() {
#endif // #ifdef Q_OS_MACOS #endif // #ifdef Q_OS_MACOS
} }
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
/// \param[in] engine The QML component. /// \param[in] engine The QML component.
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
@ -118,13 +110,12 @@ QQmlComponent *createRootQmlComponent(QQmlApplicationEngine &engine) {
rootComponent->loadUrl(QUrl(qrcQmlDir + "/Bridge.qml")); rootComponent->loadUrl(QUrl(qrcQmlDir + "/Bridge.qml"));
if (rootComponent->status() != QQmlComponent::Status::Ready) { if (rootComponent->status() != QQmlComponent::Status::Ready) {
QString const &err = rootComponent->errorString(); QString const &err = rootComponent->errorString();
app().log().error(err); app().log().error(err);
throw Exception("Could not load QML component", err); throw Exception("Could not load QML component", err);
} }
return rootComponent; return rootComponent;
} }
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
/// \param[in] lock The lock file to be checked. /// \param[in] lock The lock file to be checked.
/// \return True if the lock can be taken, false otherwise. /// \return True if the lock can be taken, false otherwise.
@ -155,7 +146,6 @@ bool checkSingleInstance(QLockFile &lock) {
return true; return true;
} }
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
/// \return QUrl to reach the bridge API. /// \return QUrl to reach the bridge API.
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
@ -184,7 +174,6 @@ QUrl getApiUrl() {
return url; return url;
} }
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
/// \brief Check if bridge is running. /// \brief Check if bridge is running.
/// ///
@ -199,7 +188,6 @@ bool isBridgeRunning() {
return (!lockFile.tryLock()) && (lockFile.error() == QLockFile::LockFailedError); return (!lockFile.tryLock()) && (lockFile.error() == QLockFile::LockFailedError);
} }
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
/// \brief Use api to bring focus on existing bridge instance. /// \brief Use api to bring focus on existing bridge instance.
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
@ -213,8 +201,7 @@ void focusOtherInstance() {
if (!sc.load(path)) { if (!sc.load(path)) {
throw Exception("The gRPC focus service configuration file is invalid."); throw Exception("The gRPC focus service configuration file is invalid.");
} }
} } else {
else {
throw Exception("Server did not provide gRPC Focus service configuration."); throw Exception("Server did not provide gRPC Focus service configuration.");
} }
@ -225,20 +212,18 @@ void focusOtherInstance() {
if (!client.raise("focusOtherInstance").ok()) { if (!client.raise("focusOtherInstance").ok()) {
throw Exception(QString("The raise call to the bridge focus service failed.")); throw Exception(QString("The raise call to the bridge focus service failed."));
} }
} } catch (Exception const &e) {
catch (Exception const &e) {
app().log().error(e.qwhat()); app().log().error(e.qwhat());
auto uuid = reportSentryException("Exception occurred during focusOtherInstance()", e); auto uuid = reportSentryException("Exception occurred during focusOtherInstance()", e);
app().log().fatal(QString("reportID: %1 Captured exception: %2").arg(QByteArray(uuid.bytes, 16).toHex(), e.qwhat())); app().log().fatal(QString("reportID: %1 Captured exception: %2").arg(QByteArray(uuid.bytes, 16).toHex(), e.qwhat()));
} }
} }
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
/// \param [in] args list of arguments to pass to bridge. /// \param [in] args list of arguments to pass to bridge.
/// \return bridge executable path /// \return bridge executable path
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
const QString launchBridge(QStringList const &args) { QString launchBridge(QStringList const &args) {
UPOverseer &overseer = app().bridgeOverseer(); UPOverseer &overseer = app().bridgeOverseer();
overseer.reset(); overseer.reset();
@ -251,26 +236,38 @@ const QString launchBridge(QStringList const &args) {
} }
qint64 const pid = qApp->applicationPid(); qint64 const pid = qApp->applicationPid();
QStringList const params = QStringList { "--grpc", "--parent-pid", QString::number(pid) } + args; QStringList const params = QStringList{"--grpc", "--parent-pid", QString::number(pid)} + args;
app().log().info(QString("Launching bridge process with command \"%1\" %2").arg(bridgeExePath, params.join(" "))); app().log().info(QString("Launching bridge process with command \"%1\" %2").arg(bridgeExePath, params.join(" ")));
overseer = std::make_unique<Overseer>(new ProcessMonitor(bridgeExePath, params, nullptr), nullptr); overseer = std::make_unique<Overseer>(new ProcessMonitor(bridgeExePath, params, nullptr), nullptr);
overseer->startWorker(true); overseer->startWorker(true);
return bridgeExePath; return bridgeExePath;
} }
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
// //
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
void closeBridgeApp() { void closeBridgeApp() {
app().grpc().quit(); // this will cause the grpc service and the bridge app to close. app().grpc().quit(); // this will cause the grpc service and the bridge app to close.
UPOverseer &overseer = app().bridgeOverseer(); UPOverseer const &overseer = app().bridgeOverseer();
if (overseer) { // A null overseer means the app was run in 'attach' mode. We're not monitoring it. if (overseer) {
// A null overseer means the app was run in 'attach' mode. We're not monitoring it.
// ReSharper disable once CppExpressionWithoutSideEffects
overseer->wait(Overseer::maxTerminationWaitTimeMs); overseer->wait(Overseer::maxTerminationWaitTimeMs);
} }
} }
//****************************************************************************************************************************************************
/// \param[in] argv The command-line argments, including the application name at index 0.
//****************************************************************************************************************************************************
void logCommandLineInvocation(QStringList argv) {
Log &log = app().log();
if (argv.isEmpty()) {
log.error("The command line is empty");
}
log.info("bridge-gui executable: " + argv.front());
log.info("Command-line invocation: " + (argv.size() > 1 ? argv.last(argv.size() - 1).join(" ") : "<none>"));
}
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
/// \param[in] argc The number of command-line arguments. /// \param[in] argc The number of command-line arguments.
@ -289,12 +286,11 @@ int main(int argc, char *argv[]) {
auto sentryCloser = qScopeGuard([] { sentry_close(); }); auto sentryCloser = qScopeGuard([] { sentry_close(); });
try { try {
QString const& configDir = bridgepp::userConfigDir(); QString const &configDir = bridgepp::userConfigDir();
initQtApplication(); initQtApplication();
QStringList const argvList = cliArgsToStringList(argc, argv);
CommandLineOptions const cliOptions = parseCommandLine(argc, argv); CommandLineOptions const cliOptions = parseCommandLine(argvList);
Log &log = initLog(); Log &log = initLog();
log.setLevel(cliOptions.logLevel); log.setLevel(cliOptions.logLevel);
@ -309,6 +305,8 @@ int main(int argc, char *argv[]) {
setDockIconVisibleState(!cliOptions.noWindow); setDockIconVisibleState(!cliOptions.noWindow);
#endif #endif
logCommandLineInvocation(argvList);
// In attached mode, we do not intercept stderr and stdout of bridge, as we did not launch it ourselves, so we output the log to the console. // In attached mode, we do not intercept stderr and stdout of bridge, as we did not launch it ourselves, so we output the log to the console.
// When not in attached mode, log entries are forwarded to bridge, which output it on stdout/stderr. bridge-gui's process monitor intercept // When not in attached mode, log entries are forwarded to bridge, which output it on stdout/stderr. bridge-gui's process monitor intercept
// these outputs and output them on the command-line. // these outputs and output them on the command-line.
@ -348,7 +346,6 @@ int main(int argc, char *argv[]) {
QQuickWindow::setSceneGraphBackend((app().settings().useSoftwareRenderer() || cliOptions.useSoftwareRenderer) ? "software" : "rhi"); QQuickWindow::setSceneGraphBackend((app().settings().useSoftwareRenderer() || cliOptions.useSoftwareRenderer) ? "software" : "rhi");
log.info(QString("Qt Quick renderer: %1").arg(QQuickWindow::sceneGraphBackend())); log.info(QString("Qt Quick renderer: %1").arg(QQuickWindow::sceneGraphBackend()));
QQmlApplicationEngine engine; QQmlApplicationEngine engine;
std::unique_ptr<QQmlComponent> rootComponent(createRootQmlComponent(engine)); std::unique_ptr<QQmlComponent> rootComponent(createRootQmlComponent(engine));
std::unique_ptr<QObject> rootObject(rootComponent->create(engine.rootContext())); std::unique_ptr<QObject> rootObject(rootComponent->create(engine.rootContext()));
@ -374,7 +371,7 @@ int main(int argc, char *argv[]) {
app().log().debug(QString("Monitoring Bridge PID : %1").arg(status.pid)); app().log().debug(QString("Monitoring Bridge PID : %1").arg(status.pid));
connection = QObject::connect(bridgeMonitor, &ProcessMonitor::processExited, [&](int returnCode) { connection = QObject::connect(bridgeMonitor, &ProcessMonitor::processExited, [&](int returnCode) {
bridgeExited = true;// clazy:exclude=lambda-in-connect bridgeExited = true; // clazy:exclude=lambda-in-connect
qGuiApp->exit(returnCode); qGuiApp->exit(returnCode);
}); });
} }
@ -383,7 +380,7 @@ int main(int argc, char *argv[]) {
int result = 0; int result = 0;
if (!startError) { if (!startError) {
// we succeeded in launching bridge, so we can be set as mainExecutable. // we succeeded in launching bridge, so we can be set as mainExecutable.
QString mainexec = QString::fromLocal8Bit(argv[0]); QString const mainexec = argvList[0];
app().grpc().setMainExecutable(mainexec); app().grpc().setMainExecutable(mainexec);
QStringList args = cliOptions.bridgeGuiArgs; QStringList args = cliOptions.bridgeGuiArgs;
args.append(waitFlag); args.append(waitFlag);
@ -412,8 +409,7 @@ int main(int argc, char *argv[]) {
// release the lock file // release the lock file
lock.unlock(); lock.unlock();
return result; return result;
} } catch (Exception const &e) {
catch (Exception const &e) {
sentry_uuid_s const uuid = reportSentryException("Exception occurred during main", e); sentry_uuid_s const uuid = reportSentryException("Exception occurred during main", e);
QString message = e.qwhat(); QString message = e.qwhat();
if (e.showSupportLink()) { if (e.showSupportLink()) {

View File

@ -60,7 +60,7 @@ QtObject {
target: Backend target: Backend
} }
} }
property var all: [root.noInternet, root.imapPortStartupError, root.smtpPortStartupError, root.imapPortChangeError, root.smtpPortChangeError, root.imapConnectionModeChangeError, root.smtpConnectionModeChangeError, root.updateManualReady, root.updateManualRestartNeeded, root.updateManualError, root.updateForce, root.updateForceError, root.updateSilentRestartNeeded, root.updateSilentError, root.updateIsLatestVersion, root.loginConnectionError, root.onlyPaidUsers, root.alreadyLoggedIn, root.enableBeta, root.bugReportSendSuccess, root.bugReportSendError, root.bugReportSendFallback, root.cacheCantMove, root.cacheLocationChangeSuccess, root.enableSplitMode, root.resetBridge, root.changeAllMailVisibility, root.deleteAccount, root.noKeychain, root.rebuildKeychain, root.addressChanged, root.apiCertIssue, root.userBadEvent, root.imapLoginWhileSignedOut, root.genericError, root.genericQuestion] property var all: [root.noInternet, root.imapPortStartupError, root.smtpPortStartupError, root.imapPortChangeError, root.smtpPortChangeError, root.imapConnectionModeChangeError, root.smtpConnectionModeChangeError, root.updateManualReady, root.updateManualRestartNeeded, root.updateManualError, root.updateForce, root.updateForceError, root.updateSilentRestartNeeded, root.updateSilentError, root.updateIsLatestVersion, root.loginConnectionError, root.onlyPaidUsers, root.alreadyLoggedIn, root.enableBeta, root.bugReportSendSuccess, root.bugReportSendError, root.bugReportSendFallback, root.cacheCantMove, root.cacheLocationChangeSuccess, root.enableSplitMode, root.resetBridge, root.changeAllMailVisibility, root.deleteAccount, root.noKeychain, root.rebuildKeychain, root.addressChanged, root.apiCertIssue, root.userBadEvent, root.imapLoginWhileSignedOut, root.genericError, root.genericQuestion, root.hvErrorEvent]
property Notification alreadyLoggedIn: Notification { property Notification alreadyLoggedIn: Notification {
brief: qsTr("Already signed in") brief: qsTr("Already signed in")
description: qsTr("This account is already signed in.") description: qsTr("This account is already signed in.")
@ -1130,6 +1130,27 @@ QtObject {
target: Backend target: Backend
} }
} }
property Notification hvErrorEvent: Notification {
group: Notifications.Group.Configuration
icon: "./icons/ic-exclamation-circle-filled.svg"
type: Notification.NotificationType.Danger
action: Action {
text: qsTr("OK")
onTriggered: {
root.hvErrorEvent.active = false;
}
}
Connections {
function onLoginHvError(errorMsg) {
root.hvErrorEvent.active = true;
root.hvErrorEvent.description = errorMsg;
}
target: Backend
}
}
signal askChangeAllMailVisibility(var isVisibleNow) signal askChangeAllMailVisibility(var isVisibleNow)
signal askDeleteAccount(var user) signal askDeleteAccount(var user)

View File

@ -17,256 +17,77 @@ import QtQuick.Controls
Item { Item {
id: root id: root
enum Screen {
CertificateInstall,
ProfileInstall
}
property var wizard property var wizard
signal appleMailAutoconfigCertificateInstallPageShown property bool profilePaneLaunched: false
signal appleMailAutoconfigProfileInstallPageShow
function showAutoconfig() { function reset() {
if (Backend.isTLSCertificateInstalled()) { profilePaneLaunched = false;
showProfileInstall();
} else {
showCertificateInstall();
}
}
function showCertificateInstall() {
certificateInstall.reset();
stack.currentIndex = ClientConfigAppleMail.Screen.CertificateInstall;
appleMailAutoconfigCertificateInstallPageShown();
}
function showProfileInstall() {
profileInstall.reset();
stack.currentIndex = ClientConfigAppleMail.Screen.ProfileInstall;
appleMailAutoconfigProfileInstallPageShow();
} }
StackLayout { ColumnLayout {
id: stack anchors.left: parent.left
anchors.fill: parent anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: ProtonStyle.wizard_spacing_large
// stack index 0 ColumnLayout {
Item {
id: certificateInstall
property string errorString: ""
property bool showBugReportLink: false
property bool waitingForCert: false
function clearError() {
errorString = "";
showBugReportLink = false;
}
function reset() {
waitingForCert = false;
clearError();
}
Layout.fillHeight: true
Layout.fillWidth: true Layout.fillWidth: true
spacing: ProtonStyle.wizard_spacing_medium
ColumnLayout { Label {
anchors.left: parent.left Layout.alignment: Qt.AlignHCenter
anchors.right: parent.right Layout.fillWidth: true
anchors.verticalCenter: parent.verticalCenter colorScheme: wizard.colorScheme
spacing: ProtonStyle.wizard_spacing_large horizontalAlignment: Text.AlignHCenter
text: qsTr("Install the profile")
Connections { type: Label.LabelType.Title
function onCertificateInstallCanceled() { wrapMode: Text.WordWrap
certificateInstall.waitingForCert = false; }
certificateInstall.errorString = qsTr("Apple Mail cannot be configured if you do not install the certificate. Please retry."); Label {
certificateInstall.showBugReportLink = false; Layout.alignment: Qt.AlignHCenter
} Layout.fillWidth: true
function onCertificateInstallFailed() { color: colorScheme.text_weak
certificateInstall.waitingForCert = false; colorScheme: wizard.colorScheme
certificateInstall.errorString = qsTr("An error occurred while installing the certificate."); horizontalAlignment: Text.AlignHCenter
certificateInstall.showBugReportLink = true; text: qsTr("A system pop-up will appear. Double click on the entry with your email, and click Install in the dialog that appears.")
} type: Label.LabelType.Body
function onCertificateInstallSuccess() { wrapMode: Text.WordWrap
certificateInstall.reset();
root.showAutoconfig();
}
target: Backend
}
ColumnLayout {
Layout.fillWidth: true
spacing: ProtonStyle.wizard_spacing_medium
Label {
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
colorScheme: wizard.colorScheme
horizontalAlignment: Text.AlignHCenter
text: qsTr("Install the bridge certificate")
type: Label.LabelType.Title
wrapMode: Text.WordWrap
}
Label {
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
color: colorScheme.text_weak
colorScheme: wizard.colorScheme
horizontalAlignment: Text.AlignHCenter
text: qsTr("After clicking on the button below, a system pop-up will ask you for your credentials, please enter your macOS user credentials (not your Proton accounts) and validate.")
type: Label.LabelType.Body
wrapMode: Text.WordWrap
}
}
Image {
Layout.alignment: Qt.AlignHCenter
height: 182
opacity: certificateInstall.waitingForCert ? 0.3 : 1.0
source: "/qml/icons/img-macos-cert-screenshot.png"
width: 140
}
ColumnLayout {
Layout.fillWidth: true
spacing: ProtonStyle.wizard_spacing_medium
Button {
Layout.fillWidth: true
colorScheme: wizard.colorScheme
enabled: !certificateInstall.waitingForCert
loading: certificateInstall.waitingForCert
text: qsTr("Install the certificate")
onClicked: {
certificateInstall.clearError();
certificateInstall.waitingForCert = true;
Backend.installTLSCertificate();
}
}
Button {
Layout.fillWidth: true
colorScheme: wizard.colorScheme
enabled: !certificateInstall.waitingForCert
secondary: true
text: qsTr("Cancel")
onClicked: {
wizard.closeWizard();
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: ProtonStyle.wizard_spacing_small
RowLayout {
Layout.fillWidth: true
spacing: ProtonStyle.wizard_spacing_extra_small
ColorImage {
color: wizard.colorScheme.signal_danger
height: errorLabel.lineHeight
source: "/qml/icons/ic-exclamation-circle-filled.svg"
sourceSize.height: errorLabel.lineHeight
visible: certificateInstall.errorString.length > 0
}
Label {
id: errorLabel
Layout.fillWidth: true
color: wizard.colorScheme.signal_danger
colorScheme: wizard.colorScheme
horizontalAlignment: Text.AlignHCenter
text: certificateInstall.errorString
type: Label.LabelType.Body_semibold
wrapMode: Text.WordWrap
}
}
LinkLabel {
Layout.alignment: Qt.AlignHCenter
callback: wizard.showBugReport
colorScheme: wizard.colorScheme
link: "#"
text: qsTr("Report the problem")
visible: certificateInstall.showBugReportLink
}
}
}
} }
} }
// stack index 1 Image {
Item { Layout.alignment: Qt.AlignHCenter
id: profileInstall height: 102
source: "/qml/icons/img-macos-profile-screenshot.png"
property bool profilePaneLaunched: false width: 364
}
function reset() { ColumnLayout {
profilePaneLaunched = false;
}
Layout.fillHeight: true
Layout.fillWidth: true Layout.fillWidth: true
spacing: ProtonStyle.wizard_spacing_medium
ColumnLayout { Button {
anchors.left: parent.left Layout.fillWidth: true
anchors.right: parent.right colorScheme: wizard.colorScheme
anchors.verticalCenter: parent.verticalCenter text: profilePaneLaunched ? qsTr("I have installed the profile") : qsTr("Install the profile")
spacing: ProtonStyle.wizard_spacing_large
ColumnLayout { onClicked: {
Layout.fillWidth: true if (profilePaneLaunched) {
spacing: ProtonStyle.wizard_spacing_medium wizard.showClientConfigEnd();
} else {
Label { wizard.user.configureAppleMail(wizard.address);
Layout.alignment: Qt.AlignHCenter profilePaneLaunched = true;
Layout.fillWidth: true
colorScheme: wizard.colorScheme
horizontalAlignment: Text.AlignHCenter
text: qsTr("Install the profile")
type: Label.LabelType.Title
wrapMode: Text.WordWrap
}
Label {
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
color: colorScheme.text_weak
colorScheme: wizard.colorScheme
horizontalAlignment: Text.AlignHCenter
text: qsTr("A system pop-up will appear. Double click on the entry with your email, and click Install in the dialog that appears.")
type: Label.LabelType.Body
wrapMode: Text.WordWrap
} }
} }
Image { }
Layout.alignment: Qt.AlignHCenter Button {
height: 102 Layout.fillWidth: true
source: "/qml/icons/img-macos-profile-screenshot.png" colorScheme: wizard.colorScheme
width: 364 secondary: true
} text: qsTr("Cancel")
ColumnLayout {
Layout.fillWidth: true
spacing: ProtonStyle.wizard_spacing_medium
Button { onClicked: {
Layout.fillWidth: true wizard.closeWizard();
colorScheme: wizard.colorScheme
text: profileInstall.profilePaneLaunched ? qsTr("I have installed the profile") : qsTr("Install the profile")
onClicked: {
if (profileInstall.profilePaneLaunched) {
wizard.showClientConfigEnd();
} else {
wizard.user.configureAppleMail(wizard.address);
profileInstall.profilePaneLaunched = true;
}
}
}
Button {
Layout.fillWidth: true
colorScheme: wizard.colorScheme
secondary: true
text: qsTr("Cancel")
onClicked: {
wizard.closeWizard();
}
}
} }
} }
} }

View File

@ -0,0 +1,153 @@
// Copyright (c) 2024 Proton AG
// This file is part of Proton Mail Bridge.
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQml
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
Item {
id: root
property string errorString: ""
property bool showBugReportLink: false
property bool waitingForCert: false
property var wizard
function clearError() {
errorString = "";
showBugReportLink = false;
}
function reset() {
waitingForCert = false;
clearError();
}
ColumnLayout {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: ProtonStyle.wizard_spacing_large
Connections {
function onCertificateInstallCanceled() {
root.waitingForCert = false;
root.errorString = qsTr("%1 cannot be configured if you do not install the certificate. Please retry.").arg(wizard.clientName());
root.showBugReportLink = false;
}
function onCertificateInstallFailed() {
root.waitingForCert = false;
root.errorString = qsTr("An error occurred while installing the certificate.");
root.showBugReportLink = true;
}
target: Backend
}
ColumnLayout {
Layout.fillWidth: true
spacing: ProtonStyle.wizard_spacing_medium
Label {
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
colorScheme: wizard.colorScheme
horizontalAlignment: Text.AlignHCenter
text: qsTr("Install the bridge certificate")
type: Label.LabelType.Title
wrapMode: Text.WordWrap
}
Label {
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
color: colorScheme.text_weak
colorScheme: wizard.colorScheme
horizontalAlignment: Text.AlignHCenter
text: qsTr("After clicking on the button below, a system pop-up will ask you for your credentials, please enter your macOS user credentials (not your Proton accounts) and validate.")
type: Label.LabelType.Body
wrapMode: Text.WordWrap
}
}
Image {
Layout.alignment: Qt.AlignHCenter
height: 182
opacity: root.waitingForCert ? 0.3 : 1.0
source: "/qml/icons/img-macos-cert-screenshot.png"
width: 140
}
ColumnLayout {
Layout.fillWidth: true
spacing: ProtonStyle.wizard_spacing_medium
Button {
Layout.fillWidth: true
colorScheme: wizard.colorScheme
enabled: !root.waitingForCert
loading: root.waitingForCert
text: qsTr("Install the certificate")
onClicked: {
root.clearError();
root.waitingForCert = true;
Backend.installTLSCertificate();
}
}
Button {
Layout.fillWidth: true
colorScheme: wizard.colorScheme
enabled: !root.waitingForCert
secondary: true
text: qsTr("Cancel")
onClicked: {
wizard.closeWizard();
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: ProtonStyle.wizard_spacing_small
RowLayout {
Layout.fillWidth: true
spacing: ProtonStyle.wizard_spacing_extra_small
ColorImage {
color: wizard.colorScheme.signal_danger
height: errorLabel.lineHeight
source: "/qml/icons/ic-exclamation-circle-filled.svg"
sourceSize.height: errorLabel.lineHeight
visible: root.errorString.length > 0
}
Label {
id: errorLabel
Layout.fillWidth: true
color: wizard.colorScheme.signal_danger
colorScheme: wizard.colorScheme
horizontalAlignment: Text.AlignHCenter
text: root.errorString
type: Label.LabelType.Body_semibold
wrapMode: Text.WordWrap
}
}
LinkLabel {
Layout.alignment: Qt.AlignHCenter
callback: wizard.showBugReport
colorScheme: wizard.colorScheme
link: "#"
text: qsTr("Report the problem")
visible: root.showBugReportLink
}
}
}
}
}

View File

@ -47,6 +47,10 @@ Item {
onClicked: { onClicked: {
wizard.client = SetupWizard.Client.AppleMail; wizard.client = SetupWizard.Client.AppleMail;
if (!Backend.isTLSCertificateInstalled()) {
wizard.showCertInstall();
return
}
wizard.showAppleMailAutoConfig(); wizard.showAppleMailAutoConfig();
} }
} }
@ -59,6 +63,10 @@ Item {
onClicked: { onClicked: {
wizard.client = SetupWizard.Client.MicrosoftOutlook; wizard.client = SetupWizard.Client.MicrosoftOutlook;
if (root.onMacOS && !Backend.isTLSCertificateInstalled()) {
wizard.showCertInstall();
return
}
wizard.showClientParams(); wizard.showClientParams();
} }
} }

View File

@ -34,13 +34,23 @@ Item {
signal startSetup() signal startSetup()
function showAppleMailAutoconfigCertificateInstall() { function showCertificateInstall() {
showAppleMailAutoconfigCommon(); showClientConfigCommon();
descriptionLabel.text = qsTr("Apple Mail configuration is mostly automated, but in order to work, Bridge needs to install a certificate in your keychain."); if (wizard.client === SetupWizard.Client.AppleMail) {
linkLabel1.setCallback(function() { Backend.openExternalLink("https://proton.me/support/apple-mail-certificate"); }, qsTr("Why is this certificate needed?"), true); descriptionLabel.text = qsTr("Apple Mail configuration is mostly automated, but in order to work, Bridge needs to install a certificate in your keychain.");
linkLabel1.setCallback(function () {
Backend.openExternalLink("https://proton.me/support/apple-mail-certificate");
}, qsTr("Why is this certificate needed?"), true);
} else {
descriptionLabel.text = qsTr("In order for Outlook to work, Bridge needs to install a certificate in your keychain.");
linkLabel1.setCallback(function () {
Backend.openExternalLink("https://proton.me/support/apple-mail-certificate");
}, qsTr("Why is this certificate needed?"), true);
}
linkLabel2.clear(); linkLabel2.clear();
} }
function showAppleMailAutoconfigCommon() {
function showClientConfigCommon() {
titleLabel.text = ""; titleLabel.text = "";
linkLabel1.clear(); linkLabel1.clear();
linkLabel2.clear(); linkLabel2.clear();
@ -49,7 +59,7 @@ Item {
iconWidth = 80; iconWidth = 80;
} }
function showAppleMailAutoconfigProfileInstall() { function showAppleMailAutoconfigProfileInstall() {
showAppleMailAutoconfigCommon(); showClientConfigCommon();
descriptionLabel.text = qsTr("The final step before you can start using Apple Mail is to install the Bridge server profile in the system preferences.\n\nAdding a server profile is necessary to ensure that your Mac can receive and send Proton Mails."); descriptionLabel.text = qsTr("The final step before you can start using Apple Mail is to install the Bridge server profile in the system preferences.\n\nAdding a server profile is necessary to ensure that your Mac can receive and send Proton Mails.");
linkLabel1.setCallback(function() { Backend.openExternalLink("https://proton.me/support/macos-certificate-warning"); }, qsTr("Why is there a yellow warning sign?"), true); linkLabel1.setCallback(function() { Backend.openExternalLink("https://proton.me/support/macos-certificate-warning"); }, qsTr("Why is there a yellow warning sign?"), true);
linkLabel2.setCallback(wizard.showClientParams, qsTr("Configure Apple Mail manually"), false); linkLabel2.setCallback(wizard.showClientParams, qsTr("Configure Apple Mail manually"), false);

View File

@ -20,12 +20,14 @@ FocusScope {
enum RootStack { enum RootStack {
Login, Login,
TOTP, TOTP,
MailboxPassword MailboxPassword,
HV
} }
property alias currentIndex: stackLayout.currentIndex property alias currentIndex: stackLayout.currentIndex
property alias username: usernameTextField.text property alias username: usernameTextField.text
property var wizard property var wizard
property string hvLinkUrl: ""
signal loginAbort(string username, bool wasSignedOut) signal loginAbort(string username, bool wasSignedOut)
@ -47,6 +49,14 @@ FocusScope {
passwordTextField.hidePassword(); passwordTextField.hidePassword();
secondPasswordTextField.hidePassword(); secondPasswordTextField.hidePassword();
} }
function resetViaHv() {
usernameTextField.enabled = false;
passwordTextField.enabled = false;
signInButton.loading = true;
secondPasswordButton.loading = false;
secondPasswordTextField.enabled = true;
totpLayout.reset();
}
StackLayout { StackLayout {
id: stackLayout id: stackLayout
@ -124,6 +134,18 @@ FocusScope {
else else
errorLabel.text = qsTr("Incorrect login credentials"); errorLabel.text = qsTr("Incorrect login credentials");
} }
function onLoginHvRequested(hvUrl) {
console.assert(stackLayout.currentIndex === Login.RootStack.Login || stackLayout.currentIndex === Login.RootStack.MailboxPassword, "Unexpected loginHvRequested");
stackLayout.currentIndex = Login.RootStack.HV;
hvUsernameLabel.text = usernameTextField.text;
hvLinkUrl = hvUrl;
}
function onLoginHvError(_) {
console.assert(stackLayout.currentIndex === Login.RootStack.Login || stackLayout.currentIndex === Login.RootStack.MailboxPassword, "Unexpected onLoginHvInvalidTokenError");
stackLayout.currentIndex = Login.RootStack.Login;
root.resetViaHv();
root.reset()
}
target: Backend target: Backend
} }
@ -475,5 +497,112 @@ FocusScope {
} }
} }
} }
Item {
id: hvLayout
ColumnLayout {
Layout.fillWidth: true
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: ProtonStyle.wizard_spacing_extra_large
ColumnLayout {
spacing: ProtonStyle.wizard_spacing_medium
ColumnLayout {
spacing: ProtonStyle.wizard_spacing_small
Label {
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
colorScheme: wizard.colorScheme
horizontalAlignment: Text.AlignHCenter
text: qsTr("Human verification")
type: Label.LabelType.Title
wrapMode: Text.WordWrap
}
Label {
id: hvUsernameLabel
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
color: wizard.colorScheme.text_weak
colorScheme: wizard.colorScheme
horizontalAlignment: Text.AlignHCenter
type: Label.LabelType.Body
}
}
Label {
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
colorScheme: wizard.colorScheme
horizontalAlignment: Text.AlignHCenter
text: qsTr("Please open the following link in your favourite web browser to verify you are human.")
type: Label.LabelType.Body
wrapMode: Text.WordWrap
}
}
Label {
id: hvRequestedUrlText
type: Label.LabelType.Lead
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
colorScheme: wizard.colorScheme
horizontalAlignment: Text.AlignLeft
text: "<a href='" + hvLinkUrl + "'>" + hvLinkUrl.replace("&", "&amp;")+ "</a>"
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
Qt.openUrlExternally(hvLinkUrl);
}
}
}
ColumnLayout {
spacing: ProtonStyle.wizard_spacing_medium
Button {
id: hVContinueButton
Layout.fillWidth: true
colorScheme: wizard.colorScheme
text: qsTr("Continue")
function checkAndSignInHv() {
console.assert(stackLayout.currentIndex === Login.RootStack.HV || stackLayout.currentIndex === Login.RootStack.MailboxPassword, "Unexpected checkInAndSignInHv")
stackLayout.currentIndex = Login.RootStack.Login
usernameTextField.validate();
passwordTextField.validate();
if (usernameTextField.error || passwordTextField.error) {
return;
}
root.resetViaHv();
Backend.loginHv(usernameTextField.text, Qt.btoa(passwordTextField.text));
}
onClicked: {
checkAndSignInHv()
}
}
Button {
Layout.fillWidth: true
colorScheme: wizard.colorScheme
secondary: true
secondaryIsOpaque: true
text: qsTr("Cancel")
onClicked: {
root.abort();
}
}
}
}
}
} }
} }

View File

@ -27,6 +27,7 @@ Item {
Onboarding, Onboarding,
Login, Login,
ClientConfigSelector, ClientConfigSelector,
ClientConfigCertInstall,
ClientConfigAppleMail ClientConfigAppleMail
} }
enum RootStack { enum RootStack {
@ -95,8 +96,9 @@ Item {
function showAppleMailAutoConfig() { function showAppleMailAutoConfig() {
backAction = _showClientConfig; backAction = _showClientConfig;
rootStackLayout.currentIndex = SetupWizard.RootStack.TwoPanesView; rootStackLayout.currentIndex = SetupWizard.RootStack.TwoPanesView;
clientConfigAppleMail.reset()
rightContent.currentIndex = SetupWizard.ContentStack.ClientConfigAppleMail; rightContent.currentIndex = SetupWizard.ContentStack.ClientConfigAppleMail;
clientConfigAppleMail.showAutoconfig(); // This will trigger signals that will display the appropriate left content. leftContent.showAppleMailAutoconfigProfileInstall();
} }
function showBugReport() { function showBugReport() {
closeWizard(); closeWizard();
@ -118,6 +120,15 @@ Item {
backAction = _showClientConfig; backAction = _showClientConfig;
rootStackLayout.currentIndex = SetupWizard.RootStack.ClientConfigParameters; rootStackLayout.currentIndex = SetupWizard.RootStack.ClientConfigParameters;
} }
function showCertInstall() {
backAction = _showClientConfig;
clientConfigCertInstall.reset();
rootStackLayout.currentIndex = SetupWizard.RootStack.TwoPanesView;
leftContent.showCertificateInstall()
rightContent.currentIndex = SetupWizard.ContentStack.ClientConfigCertInstall;
}
function showLogin(username = "") { function showLogin(username = "") {
backAction = null; backAction = null;
rootStackLayout.currentIndex = SetupWizard.RootStack.TwoPanesView; rootStackLayout.currentIndex = SetupWizard.RootStack.TwoPanesView;
@ -146,7 +157,13 @@ Item {
let address = user ? user.addresses[0] : ""; let address = user ? user.addresses[0] : "";
showClientConfig(user, address, true); showClientConfig(user, address, true);
} }
function onCertificateInstallSuccess() {
if (client === SetupWizard.Client.MicrosoftOutlook) {
showClientParams()
} else {
showAppleMailAutoConfig()
}
}
target: Backend target: Backend
} }
StackLayout { StackLayout {
@ -176,17 +193,6 @@ Item {
width: ProtonStyle.wizard_pane_width width: ProtonStyle.wizard_pane_width
wizard: root wizard: root
Connections {
function onAppleMailAutoconfigCertificateInstallPageShown() {
leftContent.showAppleMailAutoconfigCertificateInstall();
}
function onAppleMailAutoconfigProfileInstallPageShow() {
leftContent.showAppleMailAutoconfigProfileInstall();
}
target: clientConfigAppleMail
}
Connections { Connections {
function onLogin2FARequested() { function onLogin2FARequested() {
leftContent.showLogin2FA(); leftContent.showLogin2FA();
@ -247,7 +253,14 @@ Item {
id: clientConfigSelector id: clientConfigSelector
wizard: root wizard: root
} }
// rightContent stack index 3 // rightContent stack index 3
ClientConfigCertInstall {
id: clientConfigCertInstall
wizard: root
}
// rightContent stack index 4
ClientConfigAppleMail { ClientConfigAppleMail {
id: clientConfigAppleMail id: clientConfigAppleMail
wizard: root wizard: root

View File

@ -17,22 +17,22 @@
#include <bridgepp/CLI/CLIUtils.h> #include <bridgepp/CLI/CLIUtils.h>
#include <bridgepp/SessionID/SessionID.h>
#include <gtest/gtest.h> #include <gtest/gtest.h>
using namespace bridgepp; using namespace bridgepp;
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
// //
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
TEST(CLI, stripStringParameterFromCommandLine) { TEST(CLI, stripStringParameterFromCommandLine) {
struct Test { struct TestData {
QStringList input; QStringList input;
QStringList expectedOutput; QStringList expectedOutput;
}; };
QList<Test> const tests = { QList<TestData> const tests = {
{{}, {}}, {{}, {}},
{{ "--a", "-b", "--C" }, { "--a", "-b", "--C" } }, {{ "--a", "-b", "--C" }, { "--a", "-b", "--C" } },
{{ "--string", "value" }, {} }, {{ "--string", "value" }, {} },
@ -44,7 +44,45 @@ TEST(CLI, stripStringParameterFromCommandLine) {
{{ "--string", "--string", "value", "-b", "--string"}, { "value", "-b" } }, {{ "--string", "--string", "value", "-b", "--string"}, { "value", "-b" } },
}; };
for (Test const& test: tests) { for (TestData const& test: tests) {
EXPECT_EQ(stripStringParameterFromCommandLine("--string", test.input), test.expectedOutput); EXPECT_EQ(stripStringParameterFromCommandLine("--string", test.input), test.expectedOutput);
} }
} }
TEST(CLI, parseGoCLIStringArgument) {
struct TestData {
QStringList args;
QStringList params;
QStringList expectedOutput;
};
QList<TestData> const tests = {
{ {}, {}, {} },
{ {"-param"}, {"param"}, {} },
{ {"--param", "1"}, {"param"}, { "1" } },
{ {"--param", "1","p", "-p", "2", "-flag", "-param=3", "--p=4"}, {"param", "p"}, { "1", "2", "3", "4" } },
{ {"--param", "--param", "1"}, {"param"}, { "--param" } },
};
for (TestData const& test: tests) {
EXPECT_EQ(parseGoCLIStringArgument(test.args, test.params), test.expectedOutput);
}
}
TEST(CLI, cliArgsToStringList) {
int constexpr argc = 3;
char *argv[] = { const_cast<char *>("1"), const_cast<char *>("2"), const_cast<char *>("3") };
QStringList const strList { "1", "2", "3" };
EXPECT_EQ(cliArgsToStringList(argc,argv), strList);
EXPECT_EQ(cliArgsToStringList(0, nullptr), QStringList {});
}
TEST(CLI, mostRecentSessionID) {
QStringList const sessionIDs { "20220411_155931148", "20230411_155931148", "20240411_155931148" };
EXPECT_EQ(mostRecentSessionID({ hyphenatedSessionIDFlag, sessionIDs[0] }), sessionIDs[0]);
EXPECT_EQ(mostRecentSessionID({ hyphenatedSessionIDFlag, sessionIDs[1], hyphenatedSessionIDFlag, sessionIDs[2] }), sessionIDs[2]);
EXPECT_EQ(mostRecentSessionID({ hyphenatedSessionIDFlag, sessionIDs[2], hyphenatedSessionIDFlag, sessionIDs[1] }), sessionIDs[2]);
EXPECT_EQ(mostRecentSessionID({ hyphenatedSessionIDFlag, sessionIDs[1], hyphenatedSessionIDFlag, sessionIDs[2], hyphenatedSessionIDFlag,
sessionIDs[0] }), sessionIDs[2]);
}

View File

@ -16,7 +16,7 @@
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>. // along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
#include "CLIUtils.h" #include "CLIUtils.h"
#include "../SessionID/SessionID.h"
namespace bridgepp { namespace bridgepp {
@ -42,4 +42,67 @@ QStringList stripStringParameterFromCommandLine(QString const &paramName, QStrin
} }
//****************************************************************************************************************************************************
/// The flags may be present more than once in the args. All values are returned in order of appearance.
///
/// \param[in] args The arguments
/// \param[in] paramNames the list of names for the parameter, without any prefix hypen.
/// \return The values found for the flag.
//****************************************************************************************************************************************************
QStringList parseGoCLIStringArgument(QStringList const &args, QStringList const& paramNames) {
// go cli package is pretty permissive when it comes to parsing arguments. For each name 'param', all the following seems to be accepted:
// -param value
// --param value
// -param=value
// --param=value
QStringList result;
qsizetype const argCount = args.count();
for (qsizetype i = 0; i < args.size(); ++i) {
for (QString const &paramName: paramNames) {
if ((i < argCount - 1) && ((args[i] == "-" + paramName) || (args[i] == "--" + paramName))) {
result.append(args[i + 1]);
i += 1;
continue;
}
if (QRegularExpressionMatch match = QRegularExpression(QString("^-{1,2}%1=(.+)$").arg(paramName)).match(args[i]); match.hasMatch()) {
result.append(match.captured(1));
continue;
}
}
}
return result;
}
//****************************************************************************************************************************************************
/// \param[in] argc The number of command-line arguments.
/// \param[in] argv The list of command-line arguments.
/// \return A QStringList representing the arguments list.
//****************************************************************************************************************************************************
QStringList cliArgsToStringList(int argc, char **argv) {
QStringList result;
result.reserve(argc);
for (qsizetype i = 0; i < argc; ++i) {
result.append(QString::fromLocal8Bit(argv[i]));
}
return result;
}
//****************************************************************************************************************************************************
/// \param[in] args The command-line arguments.
/// \return The most recent sessionID in the list. If the list is empty, a new sessionID is created.
//****************************************************************************************************************************************************
QString mostRecentSessionID(QStringList const& args) {
QStringList const sessionIDs = parseGoCLIStringArgument(args, {sessionIDFlag});
if (sessionIDs.isEmpty()) {
return newSessionID();
}
return *std::max_element(sessionIDs.constBegin(), sessionIDs.constEnd(), [](QString const &lhs, QString const &rhs) -> bool {
return sessionIDToDateTime(lhs) < sessionIDToDateTime(rhs);
});
}
} // namespace bridgepp } // namespace bridgepp

View File

@ -15,18 +15,16 @@
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>. // along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
#ifndef BRIDGEPP_CLI_UTILS_H #ifndef BRIDGEPP_CLI_UTILS_H
#define BRIDGEPP_CLI_UTILS_H #define BRIDGEPP_CLI_UTILS_H
namespace bridgepp { namespace bridgepp {
QStringList stripStringParameterFromCommandLine(QString const &paramName, QStringList const &commandLineParams); ///< Remove a string parameter from a list of command-line parameters. QStringList stripStringParameterFromCommandLine(QString const &paramName, QStringList const &commandLineParams); ///< Remove a string parameter from a list of command-line parameters.
QStringList parseGoCLIStringArgument(QStringList const &args, QStringList const &paramNames); ///< Parse a command-line string argument as expected by go's CLI package.
QStringList cliArgsToStringList(int argc, char **argv); ///< Converts C-style command-line arguments to a string list.
QString mostRecentSessionID(QStringList const& args); ///< Returns the most recent sessionID parsed in command-line arguments.
} }
#endif // BRIDGEPP_CLI_UTILS_H #endif // BRIDGEPP_CLI_UTILS_H

View File

@ -302,6 +302,18 @@ SPStreamEvent newLoginTfaRequestedEvent(QString const &username) {
} }
//****************************************************************************************************************************************************
/// \return The event.
//****************************************************************************************************************************************************
SPStreamEvent newLoginHvRequestedEvent() {
auto event = new ::grpc::LoginHvRequestedEvent;
event->set_hvurl("https://verify.proton.me/?methods=captcha&token=SOME_RANDOM_TOKEN");
auto loginEvent = new grpc::LoginEvent;
loginEvent->set_allocated_hvrequested(event);
return wrapLoginEvent(loginEvent);
}
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
/// \param[in] username The username. /// \param[in] username The username.
/// \return The event. /// \return The event.

View File

@ -48,6 +48,7 @@ SPStreamEvent newLoginTfaRequestedEvent(QString const &username); ///< Create a
SPStreamEvent newLoginTwoPasswordsRequestedEvent(QString const &username); ///< Create a new LoginTwoPasswordsRequestedEvent event. SPStreamEvent newLoginTwoPasswordsRequestedEvent(QString const &username); ///< Create a new LoginTwoPasswordsRequestedEvent event.
SPStreamEvent newLoginFinishedEvent(QString const &userID, bool wasSignedOut); ///< Create a new LoginFinishedEvent event. SPStreamEvent newLoginFinishedEvent(QString const &userID, bool wasSignedOut); ///< Create a new LoginFinishedEvent event.
SPStreamEvent newLoginAlreadyLoggedInEvent(QString const &userID); ///< Create a new LoginAlreadyLoggedInEvent event. SPStreamEvent newLoginAlreadyLoggedInEvent(QString const &userID); ///< Create a new LoginAlreadyLoggedInEvent event.
SPStreamEvent newLoginHvRequestedEvent(); ///< Create a new LoginHvRequestedEvent
// Update related events // Update related events
SPStreamEvent newUpdateErrorEvent(grpc::UpdateErrorType errorType); ///< Create a new UpdateErrorEvent event. SPStreamEvent newUpdateErrorEvent(grpc::UpdateErrorType errorType); ///< Create a new UpdateErrorEvent event.

View File

@ -23,7 +23,6 @@
#include "../ProcessMonitor.h" #include "../ProcessMonitor.h"
#include "../Log/LogUtils.h" #include "../Log/LogUtils.h"
using namespace google::protobuf; using namespace google::protobuf;
using namespace grpc; using namespace grpc;
@ -607,6 +606,20 @@ grpc::Status GRPCClient::login(QString const &username, QString const &password)
} }
//****************************************************************************************************************************************************
/// \param[in] username The username.
/// \param[in] password The password.
/// \return the status for the gRPC call.
//****************************************************************************************************************************************************
grpc::Status GRPCClient::loginHv(QString const &username, QString const &password) {
LoginRequest request;
request.set_username(username.toStdString());
request.set_password(password.toStdString());
request.set_usehvdetails(true);
return this->logGRPCCallStatus(stub_->Login(this->clientContext().get(), request, &empty), __FUNCTION__);
}
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
/// \param[in] username The username. /// \param[in] username The username.
/// \param[in] code The The 2FA code. /// \param[in] code The The 2FA code.
@ -1221,6 +1234,9 @@ void GRPCClient::processLoginEvent(LoginEvent const &event) {
case TWO_PASSWORDS_ABORT: case TWO_PASSWORDS_ABORT:
emit login2PasswordErrorAbort(QString::fromStdString(error.message())); emit login2PasswordErrorAbort(QString::fromStdString(error.message()));
break; break;
case HV_ERROR:
emit loginHvError(QString::fromStdString(error.message()));
break;
default: default:
this->logError("Unknown login error event received."); this->logError("Unknown login error event received.");
break; break;
@ -1245,6 +1261,10 @@ void GRPCClient::processLoginEvent(LoginEvent const &event) {
this->logTrace("Login event received: AlreadyLoggedIn."); this->logTrace("Login event received: AlreadyLoggedIn.");
emit loginAlreadyLoggedIn(QString::fromStdString(event.finished().userid())); emit loginAlreadyLoggedIn(QString::fromStdString(event.finished().userid()));
break; break;
case LoginEvent::kHvRequested:
this->logTrace("Login event Received: HvRequested");
emit loginHvRequested(QString::fromStdString(event.hvrequested().hvurl()));
break;
default: default:
this->logError("Unknown Login event received."); this->logError("Unknown Login event received.");
break; break;

View File

@ -155,6 +155,7 @@ public: // login related calls
grpc::Status login2FA(QString const &username, QString const &code); ///< Performs the 'login2FA' call. grpc::Status login2FA(QString const &username, QString const &code); ///< Performs the 'login2FA' call.
grpc::Status login2Passwords(QString const &username, QString const &password); ///< Performs the 'login2Passwords' call. grpc::Status login2Passwords(QString const &username, QString const &password); ///< Performs the 'login2Passwords' call.
grpc::Status loginAbort(QString const &username); ///< Performs the 'loginAbort' call. grpc::Status loginAbort(QString const &username); ///< Performs the 'loginAbort' call.
grpc::Status loginHv(QString const &username, QString const &password); ///< Performs the 'login' call with additional useHv flag
signals: signals:
void loginUsernamePasswordError(QString const &errMsg); void loginUsernamePasswordError(QString const &errMsg);
@ -168,6 +169,8 @@ signals:
void login2PasswordErrorAbort(QString const &errMsg); void login2PasswordErrorAbort(QString const &errMsg);
void loginFinished(QString const &userID, bool wasSignedOut); void loginFinished(QString const &userID, bool wasSignedOut);
void loginAlreadyLoggedIn(QString const &userID); void loginAlreadyLoggedIn(QString const &userID);
void loginHvRequested(QString const &hvUrl);
void loginHvError(QString const &errMsg);
public: // Update related calls public: // Update related calls
grpc::Status checkUpdate(); grpc::Status checkUpdate();

View File

@ -32,6 +32,10 @@ QString const dateTimeFormat = "yyyyMMdd_hhmmsszzz"; ///< The format string for
namespace bridgepp { namespace bridgepp {
QString const sessionIDFlag = "session-id";
QString const hyphenatedSessionIDFlag = "--" + sessionIDFlag;
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
/// \return a new session ID based on the current local date/time /// \return a new session ID based on the current local date/time
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************

View File

@ -23,6 +23,10 @@
namespace bridgepp { namespace bridgepp {
extern QString const sessionIDFlag; ///< The sessionID command-line flag (without hyphens)
extern QString const hyphenatedSessionIDFlag; ///< The sessionID command-line flag (with two hyphens)
QString newSessionID(); ///< Create a new sessions QString newSessionID(); ///< Create a new sessions
QDateTime sessionIDToDateTime(QString const &sessionID); ///< Parse the date/time from a sessionID. QDateTime sessionIDToDateTime(QString const &sessionID); ///< Parse the date/time from a sessionID.

View File

@ -19,12 +19,14 @@ package cli
import ( import (
"context" "context"
"fmt"
"strings" "strings"
"github.com/ProtonMail/go-proton-api" "github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal/bridge" "github.com/ProtonMail/proton-bridge/v3/internal/bridge"
"github.com/ProtonMail/proton-bridge/v3/internal/certs" "github.com/ProtonMail/proton-bridge/v3/internal/certs"
"github.com/ProtonMail/proton-bridge/v3/internal/constants" "github.com/ProtonMail/proton-bridge/v3/internal/constants"
"github.com/ProtonMail/proton-bridge/v3/internal/hv"
"github.com/ProtonMail/proton-bridge/v3/internal/vault" "github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/abiosoft/ishell" "github.com/abiosoft/ishell"
) )
@ -116,6 +118,13 @@ func (f *frontendCLI) showAccountAddressInfo(user bridge.UserInfo, address strin
f.Println("") f.Println("")
} }
func (f *frontendCLI) promptHvURL(details *proton.APIHVDetails) {
hvURL := hv.FormatHvURL(details)
fmt.Print("\nHuman Verification requested. Please open the URL below in a browser and press ENTER when the challenge has been completed.\n\n", hvURL+"\n")
f.ReadLine()
fmt.Println("Authenticating ...")
}
func (f *frontendCLI) loginAccount(c *ishell.Context) { func (f *frontendCLI) loginAccount(c *ishell.Context) {
f.ShowPrompt(false) f.ShowPrompt(false)
defer f.ShowPrompt(true) defer f.ShowPrompt(true)
@ -144,7 +153,19 @@ func (f *frontendCLI) loginAccount(c *ishell.Context) {
f.Println("Authenticating ... ") f.Println("Authenticating ... ")
client, auth, err := f.bridge.LoginAuth(context.Background(), loginName, []byte(password)) client, auth, err := f.bridge.LoginAuth(context.Background(), loginName, []byte(password), nil)
var hvDetails *proton.APIHVDetails
hvDetails, hvErr := hv.VerifyAndExtractHvRequest(err)
if hvErr != nil || hvDetails != nil {
if hvErr != nil {
f.printAndLogError("Cannot login", hvErr)
return
}
f.promptHvURL(hvDetails)
client, auth, err = f.bridge.LoginAuth(context.Background(), loginName, []byte(password), hvDetails)
}
if err != nil { if err != nil {
f.printAndLogError("Cannot login: ", err) f.printAndLogError("Cannot login: ", err)
return return
@ -175,7 +196,55 @@ func (f *frontendCLI) loginAccount(c *ishell.Context) {
keyPass = []byte(password) keyPass = []byte(password)
} }
userID, err := f.bridge.LoginUser(context.Background(), client, auth, keyPass) userID, err := f.bridge.LoginUser(context.Background(), client, auth, keyPass, hvDetails)
hvDetails, hvErr = hv.VerifyAndExtractHvRequest(err)
if hvDetails != nil || hvErr != nil {
if hvErr != nil {
f.printAndLogError("Cannot login: ", hvErr)
return
}
f.loginAccountHv(c, loginName, password, keyPass, hvDetails)
return
}
if err != nil {
f.processAPIError(err)
return
}
user, err := f.bridge.GetUserInfo(userID)
if err != nil {
panic(err)
}
f.Printf("Account %s was added successfully.\n", bold(user.Username))
}
func (f *frontendCLI) loginAccountHv(c *ishell.Context, loginName string, password string, keyPass []byte, hvDetails *proton.APIHVDetails) {
f.promptHvURL(hvDetails)
client, auth, err := f.bridge.LoginAuth(context.Background(), loginName, []byte(password), hvDetails)
if err != nil {
f.printAndLogError("Cannot login: ", err)
return
}
if auth.TwoFA.Enabled&proton.HasTOTP != 0 {
code := f.readStringInAttempts("Two factor code", c.ReadLine, isNotEmpty)
if code == "" {
f.printAndLogError("Cannot login: need two factor code")
return
}
if err := client.Auth2FA(context.Background(), proton.Auth2FAReq{TwoFactorCode: code}); err != nil {
f.printAndLogError("Cannot login: ", err)
return
}
}
userID, err := f.bridge.LoginUser(context.Background(), client, auth, keyPass, hvDetails)
if err != nil { if err != nil {
f.processAPIError(err) f.processAPIError(err)
return return

File diff suppressed because it is too large Load Diff

View File

@ -168,6 +168,7 @@ message ReportBugRequest {
message LoginRequest { message LoginRequest {
string username = 1; string username = 1;
bytes password = 2; bytes password = 2;
optional bool useHvDetails = 3;
} }
message LoginAbortRequest { message LoginAbortRequest {
@ -308,6 +309,7 @@ message LoginEvent {
LoginTwoPasswordsRequestedEvent twoPasswordRequested = 3; LoginTwoPasswordsRequestedEvent twoPasswordRequested = 3;
LoginFinishedEvent finished = 4; LoginFinishedEvent finished = 4;
LoginFinishedEvent alreadyLoggedIn = 5; LoginFinishedEvent alreadyLoggedIn = 5;
LoginHvRequestedEvent hvRequested = 6;
} }
} }
@ -319,6 +321,7 @@ enum LoginErrorType {
TFA_ABORT = 4; TFA_ABORT = 4;
TWO_PASSWORDS_ERROR = 5; TWO_PASSWORDS_ERROR = 5;
TWO_PASSWORDS_ABORT = 6; TWO_PASSWORDS_ABORT = 6;
HV_ERROR = 7;
} }
message LoginErrorEvent { message LoginErrorEvent {
@ -339,6 +342,10 @@ message LoginFinishedEvent {
bool wasSignedOut = 2; bool wasSignedOut = 2;
} }
message LoginHvRequestedEvent {
string hvUrl = 1;
}
//********************************************************** //**********************************************************
// Update related events // Update related events
//********************************************************** //**********************************************************

View File

@ -100,6 +100,10 @@ func NewLoginAlreadyLoggedInEvent(userID string) *StreamEvent {
return loginEvent(&LoginEvent{Event: &LoginEvent_AlreadyLoggedIn{AlreadyLoggedIn: &LoginFinishedEvent{UserID: userID}}}) return loginEvent(&LoginEvent{Event: &LoginEvent_AlreadyLoggedIn{AlreadyLoggedIn: &LoginFinishedEvent{UserID: userID}}})
} }
func NewLoginHvRequestedEvent(hvChallengeURL string) *StreamEvent {
return loginEvent(&LoginEvent{Event: &LoginEvent_HvRequested{HvRequested: &LoginHvRequestedEvent{HvUrl: hvChallengeURL}}})
}
func NewUpdateErrorEvent(errorType UpdateErrorType) *StreamEvent { func NewUpdateErrorEvent(errorType UpdateErrorType) *StreamEvent {
return updateEvent(&UpdateEvent{Event: &UpdateEvent_Error{Error: &UpdateErrorEvent{Type: errorType}}}) return updateEvent(&UpdateEvent{Event: &UpdateEvent_Error{Error: &UpdateErrorEvent{Type: errorType}}})
} }

View File

@ -38,6 +38,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/bridge" "github.com/ProtonMail/proton-bridge/v3/internal/bridge"
"github.com/ProtonMail/proton-bridge/v3/internal/certs" "github.com/ProtonMail/proton-bridge/v3/internal/certs"
"github.com/ProtonMail/proton-bridge/v3/internal/events" "github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/hv"
"github.com/ProtonMail/proton-bridge/v3/internal/safe" "github.com/ProtonMail/proton-bridge/v3/internal/safe"
"github.com/ProtonMail/proton-bridge/v3/internal/service" "github.com/ProtonMail/proton-bridge/v3/internal/service"
"github.com/ProtonMail/proton-bridge/v3/internal/updater" "github.com/ProtonMail/proton-bridge/v3/internal/updater"
@ -95,6 +96,9 @@ type Service struct { // nolint:structcheck
parentPID int parentPID int
parentPIDDoneCh chan struct{} parentPIDDoneCh chan struct{}
showOnStartup bool showOnStartup bool
hvDetails *proton.APIHVDetails
useHvDetails bool
} }
// NewService returns a new instance of the service. // NewService returns a new instance of the service.
@ -412,6 +416,7 @@ func (s *Service) loginClean() {
s.password[i] = '\x00' s.password[i] = '\x00'
} }
s.password = s.password[0:0] s.password = s.password[0:0]
s.useHvDetails = false
} }
func (s *Service) finishLogin() { func (s *Service) finishLogin() {
@ -424,6 +429,11 @@ func (s *Service) finishLogin() {
wasSignedOut := s.bridge.HasUser(s.auth.UserID) wasSignedOut := s.bridge.HasUser(s.auth.UserID)
var hvDetails *proton.APIHVDetails
if s.useHvDetails {
hvDetails = s.hvDetails
}
if len(s.password) == 0 || s.auth.UID == "" || s.authClient == nil { if len(s.password) == 0 || s.auth.UID == "" || s.authClient == nil {
s.log. s.log.
WithField("hasPass", len(s.password) != 0). WithField("hasPass", len(s.password) != 0).
@ -439,8 +449,20 @@ func (s *Service) finishLogin() {
defer done() defer done()
ctx := context.Background() ctx := context.Background()
userID, err := s.bridge.LoginUser(ctx, s.authClient, s.auth, s.password) userID, err := s.bridge.LoginUser(ctx, s.authClient, s.auth, s.password, hvDetails)
if err != nil { if err != nil {
if hv.IsHvRequest(err) {
s.handleHvRequest(err)
performCleanup = false
return
}
if apiErr := new(proton.APIError); errors.As(err, &apiErr) && apiErr.Code == proton.HumanValidationInvalidToken {
s.hvDetails = nil
_ = s.SendEvent(NewLoginError(LoginErrorType_HV_ERROR, err.Error()))
return
}
s.log.WithError(err).Errorf("Finish login failed") s.log.WithError(err).Errorf("Finish login failed")
s.twoPasswordAttemptCount++ s.twoPasswordAttemptCount++
errType := LoginErrorType_TWO_PASSWORDS_ABORT errType := LoginErrorType_TWO_PASSWORDS_ABORT
@ -614,6 +636,18 @@ func (s *Service) monitorParentPID() {
} }
} }
func (s *Service) handleHvRequest(err error) {
hvDet, hvErr := hv.VerifyAndExtractHvRequest(err)
if hvErr != nil {
_ = s.SendEvent(NewLoginError(LoginErrorType_HV_ERROR, hvErr.Error()))
return
}
s.hvDetails = hvDet
hvChallengeURL := hv.FormatHvURL(hvDet)
_ = s.SendEvent(NewLoginHvRequestedEvent(hvChallengeURL))
}
// computeFileSocketPath Return an available path for a socket file in the temp folder. // computeFileSocketPath Return an available path for a socket file in the temp folder.
func computeFileSocketPath() (string, error) { func computeFileSocketPath() (string, error) {
tempPath := os.TempDir() tempPath := os.TempDir()

View File

@ -396,6 +396,14 @@ func (s *Service) RequestKnowledgeBaseSuggestions(_ context.Context, userInput *
func (s *Service) Login(_ context.Context, login *LoginRequest) (*emptypb.Empty, error) { func (s *Service) Login(_ context.Context, login *LoginRequest) (*emptypb.Empty, error) {
s.log.WithField("username", login.Username).Debug("Login") s.log.WithField("username", login.Username).Debug("Login")
var hvDetails *proton.APIHVDetails
if login.UseHvDetails != nil && *login.UseHvDetails {
hvDetails = s.hvDetails
s.useHvDetails = true
} else {
s.useHvDetails = false
}
go func() { go func() {
defer async.HandlePanic(s.panicHandler) defer async.HandlePanic(s.panicHandler)
@ -407,7 +415,7 @@ func (s *Service) Login(_ context.Context, login *LoginRequest) (*emptypb.Empty,
return return
} }
client, auth, err := s.bridge.LoginAuth(context.Background(), login.Username, password) client, auth, err := s.bridge.LoginAuth(context.Background(), login.Username, password, hvDetails)
if err != nil { if err != nil {
defer s.loginClean() defer s.loginClean()
@ -421,6 +429,13 @@ func (s *Service) Login(_ context.Context, login *LoginRequest) (*emptypb.Empty,
case proton.PaidPlanRequired: case proton.PaidPlanRequired:
_ = s.SendEvent(NewLoginError(LoginErrorType_FREE_USER, "")) _ = s.SendEvent(NewLoginError(LoginErrorType_FREE_USER, ""))
case proton.HumanVerificationRequired:
s.handleHvRequest(apiErr)
case proton.HumanValidationInvalidToken:
s.hvDetails = nil
_ = s.SendEvent(NewLoginError(LoginErrorType_HV_ERROR, err.Error()))
default: default:
_ = s.SendEvent(NewLoginError(LoginErrorType_USERNAME_PASSWORD_ERROR, err.Error())) _ = s.SendEvent(NewLoginError(LoginErrorType_USERNAME_PASSWORD_ERROR, err.Error()))
} }
@ -522,7 +537,6 @@ func (s *Service) LoginAbort(_ context.Context, loginAbort *LoginAbortRequest) (
go func() { go func() {
defer async.HandlePanic(s.panicHandler) defer async.HandlePanic(s.panicHandler)
s.loginAbort() s.loginAbort()
}() }()

62
internal/hv/hv.go Normal file
View File

@ -0,0 +1,62 @@
// Copyright (c) 2024 Proton AG
// This file is part of Proton Mail Bridge.
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package hv
import (
"errors"
"fmt"
"strings"
"github.com/ProtonMail/go-proton-api"
)
// VerifyAndExtractHvRequest expects an error request as input
// determines whether the given error is a Proton human verification request; if it isn't then it returns -> nil, nil (no details, no error)
// if it is a HV req. then it tries to parse the json data and verify that the captcha method is included; if either fails -> nil, err
// if the HV request was successfully decoded and the preconditions were met it returns the hv details -> hvDetails, nil.
func VerifyAndExtractHvRequest(err error) (*proton.APIHVDetails, error) {
if err == nil {
return nil, nil
}
var protonErr *proton.APIError
if errors.As(err, &protonErr) && protonErr.IsHVError() {
hvDetails, hvErr := protonErr.GetHVDetails()
if hvErr != nil {
return nil, fmt.Errorf("received HV request, but can't decode HV details")
}
return hvDetails, nil
}
return nil, nil
}
func IsHvRequest(err error) bool {
if err == nil {
return false
}
var protonErr *proton.APIError
if errors.As(err, &protonErr) && protonErr.IsHVError() {
return true
}
return false
}
func FormatHvURL(details *proton.APIHVDetails) string {
return fmt.Sprintf("https://verify.proton.me/?methods=%v&token=%v",
strings.Join(details.Methods, ","),
details.Token)
}

144
internal/hv/hv_test.go Normal file
View File

@ -0,0 +1,144 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package hv
import (
"encoding/json"
"fmt"
"testing"
"github.com/ProtonMail/go-proton-api"
"github.com/stretchr/testify/require"
)
func TestVerifyAndExtractHvRequest(t *testing.T) {
det1, _ := json.Marshal("test")
det2, _ := json.Marshal(proton.APIHVDetails{Methods: []string{"ownership-email"}, Token: "test"})
det3, _ := json.Marshal(proton.APIHVDetails{Methods: []string{"captcha"}, Token: "test"})
det4, _ := json.Marshal(proton.APIHVDetails{Methods: []string{"ownership-email", "test"}, Token: "test"})
det5, _ := json.Marshal(proton.APIHVDetails{Methods: []string{"captcha", "ownership-email"}, Token: "test"})
tests := []struct {
err error
hasHvDetails bool
hasErr bool
}{
{err: nil,
hasHvDetails: false,
hasErr: false},
{err: fmt.Errorf("test"),
hasHvDetails: false,
hasErr: false},
{err: new(proton.APIError),
hasHvDetails: false,
hasErr: false},
{err: &proton.APIError{Status: 429},
hasHvDetails: false,
hasErr: false},
{err: &proton.APIError{Status: 9001},
hasHvDetails: false,
hasErr: false},
{err: &proton.APIError{Code: 9001},
hasHvDetails: false,
hasErr: true},
{err: &proton.APIError{Code: 9001, Details: det1},
hasHvDetails: false,
hasErr: true},
{err: &proton.APIError{Code: 9001, Details: det2},
hasHvDetails: true,
hasErr: false},
{err: &proton.APIError{Code: 9001, Details: det3},
hasHvDetails: true,
hasErr: false},
{err: &proton.APIError{Code: 9001, Details: det4},
hasHvDetails: true,
hasErr: false},
{err: &proton.APIError{Code: 9001, Details: det5},
hasHvDetails: true,
hasErr: false},
}
for _, test := range tests {
hvDetails, err := VerifyAndExtractHvRequest(test.err)
hasHv := hvDetails != nil
hasErr := err != nil
require.True(t, hasHv == test.hasHvDetails && hasErr == test.hasErr)
}
}
func TestIsHvRequest(t *testing.T) {
tests := []struct {
err error
result bool
}{
{
err: nil,
result: false,
},
{
err: fmt.Errorf("test"),
result: false,
},
{
err: new(proton.APIError),
result: false,
},
{
err: &proton.APIError{Status: 429},
result: false,
},
{
err: &proton.APIError{Status: 9001},
result: false,
},
{
err: &proton.APIError{Code: 9001},
result: true,
},
}
for _, test := range tests {
isHvRequest := IsHvRequest(test.err)
require.Equal(t, test.result, isHvRequest)
}
}
func TestFormatHvURL(t *testing.T) {
tests := []struct {
details *proton.APIHVDetails
result string
}{
{
details: &proton.APIHVDetails{Methods: []string{"test"}, Token: "test"},
result: "https://verify.proton.me/?methods=test&token=test",
},
{
details: &proton.APIHVDetails{Methods: []string{""}, Token: "test"},
result: "https://verify.proton.me/?methods=&token=test",
},
{
details: &proton.APIHVDetails{Methods: []string{"test"}, Token: ""},
result: "https://verify.proton.me/?methods=test&token=",
},
}
for _, el := range tests {
result := FormatHvURL(el.details)
require.Equal(t, el.result, result)
}
}

View File

@ -23,12 +23,15 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/mail" "net/mail"
"strings"
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/ProtonMail/gluon/async" "github.com/ProtonMail/gluon/async"
"github.com/ProtonMail/gluon/connector" "github.com/ProtonMail/gluon/connector"
"github.com/ProtonMail/gluon/imap" "github.com/ProtonMail/gluon/imap"
"github.com/ProtonMail/gluon/reporter"
"github.com/ProtonMail/gluon/rfc5322"
"github.com/ProtonMail/gluon/rfc822" "github.com/ProtonMail/gluon/rfc822"
"github.com/ProtonMail/go-proton-api" "github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/gopenpgp/v2/crypto" "github.com/ProtonMail/gopenpgp/v2/crypto"
@ -54,6 +57,7 @@ type Connector struct {
identityState sharedIdentity identityState sharedIdentity
client APIClient client APIClient
telemetry Telemetry telemetry Telemetry
reporter reporter.Reporter
panicHandler async.PanicHandler panicHandler async.PanicHandler
sendRecorder *sendrecorder.SendRecorder sendRecorder *sendrecorder.SendRecorder
@ -75,6 +79,7 @@ func NewConnector(
sendRecorder *sendrecorder.SendRecorder, sendRecorder *sendrecorder.SendRecorder,
panicHandler async.PanicHandler, panicHandler async.PanicHandler,
telemetry Telemetry, telemetry Telemetry,
reporter reporter.Reporter,
showAllMail bool, showAllMail bool,
syncState *SyncState, syncState *SyncState,
) *Connector { ) *Connector {
@ -90,6 +95,7 @@ func NewConnector(
client: apiClient, client: apiClient,
telemetry: telemetry, telemetry: telemetry,
reporter: reporter,
panicHandler: panicHandler, panicHandler: panicHandler,
sendRecorder: sendRecorder, sendRecorder: sendRecorder,
@ -279,7 +285,7 @@ func (s *Connector) CreateMessage(ctx context.Context, _ connector.IMAPStateWrit
if messageID, ok, err := s.sendRecorder.HasEntryWait(ctx, hash, time.Now().Add(90*time.Second), toList); err != nil { if messageID, ok, err := s.sendRecorder.HasEntryWait(ctx, hash, time.Now().Add(90*time.Second), toList); err != nil {
return imap.Message{}, nil, fmt.Errorf("failed to check send hash: %w", err) return imap.Message{}, nil, fmt.Errorf("failed to check send hash: %w", err)
} else if ok { } else if ok {
s.log.WithField("messageID", messageID).Warn("Message already sent") s.log.WithField("messageID", messageID).Warn("Message already in sent mailbox")
// Query the server-side message. // Query the server-side message.
full, err := s.client.GetFullMessage(ctx, messageID, usertypes.NewProtonAPIScheduler(s.panicHandler), proton.NewDefaultAttachmentAllocator()) full, err := s.client.GetFullMessage(ctx, messageID, usertypes.NewProtonAPIScheduler(s.panicHandler), proton.NewDefaultAttachmentAllocator())
@ -671,11 +677,21 @@ func (s *Connector) importMessage(
) (imap.Message, []byte, error) { ) (imap.Message, []byte, error) {
var full proton.FullMessage var full proton.FullMessage
// addr is primary for combined mode or active for split mode
addr, ok := s.identityState.GetAddress(s.addrID) addr, ok := s.identityState.GetAddress(s.addrID)
if !ok { if !ok {
return imap.Message{}, nil, fmt.Errorf("could not find address") return imap.Message{}, nil, fmt.Errorf("could not find address")
} }
p, err2 := parser.New(bytes.NewReader(literal))
if err2 != nil {
return imap.Message{}, nil, fmt.Errorf("failed to parse literal: %w", err2)
}
isDraft := slices.Contains(labelIDs, proton.DraftsLabel)
s.reportGODT3185(isDraft, addr.Email, p, s.addressMode == usertypes.AddressModeCombined)
if err := s.identityState.WithAddrKR(s.addrID, func(_, addrKR *crypto.KeyRing) error { if err := s.identityState.WithAddrKR(s.addrID, func(_, addrKR *crypto.KeyRing) error {
primaryKey, errKey := addrKR.FirstKey() primaryKey, errKey := addrKR.FirstKey()
if errKey != nil { if errKey != nil {
@ -683,11 +699,8 @@ func (s *Connector) importMessage(
} }
var messageID string var messageID string
p, err2 := parser.New(bytes.NewReader(literal))
if err2 != nil { if isDraft {
return fmt.Errorf("failed to parse literal: %w", err2)
}
if slices.Contains(labelIDs, proton.DraftsLabel) {
msg, err := s.createDraftWithParser(ctx, p, primaryKey, addr) msg, err := s.createDraftWithParser(ctx, p, primaryKey, addr)
if err != nil { if err != nil {
return fmt.Errorf("failed to create draft: %w", err) return fmt.Errorf("failed to create draft: %w", err)
@ -850,3 +863,94 @@ func defaultMailboxPermanentFlags() imap.FlagSet {
func defaultMailboxAttributes() imap.FlagSet { func defaultMailboxAttributes() imap.FlagSet {
return imap.NewFlagSet() return imap.NewFlagSet()
} }
func stripPlusAlias(a string) string {
iPlus := strings.Index(a, "+")
iAt := strings.Index(a, "@")
if iPlus <= 0 || iAt <= 0 || iPlus >= iAt {
return a
}
return a[:iPlus] + a[iAt:]
}
func equalAddresses(a, b string) bool {
return strings.EqualFold(stripPlusAlias(a), stripPlusAlias(b))
}
func (s *Connector) reportGODT3185(isDraft bool, defaultAddr string, p *parser.Parser, isCombinedMode bool) {
reportAction := "draft"
if !isDraft {
reportAction = "import"
}
reportMode := "combined"
if !isCombinedMode {
reportMode = "split"
}
senderAddr := ""
if p != nil && p.Root() != nil && p.Root().Header.Len() != 0 {
addrField := p.Root().Header.Get("From")
if addrField == "" {
addrField = p.Root().Header.Get("Sender")
}
if addrField != "" {
sender, err := rfc5322.ParseAddressList(addrField)
if err == nil && len(sender) > 0 {
senderAddr = sender[0].Address
} else {
s.log.WithError(err).Warn("Invalid sender address in reporter")
}
}
}
if equalAddresses(defaultAddr, senderAddr) {
return
}
isDisabled := false
isUserAddress := false
for _, a := range s.identityState.GetAddresses() {
if !equalAddresses(a.Email, senderAddr) {
continue
}
isUserAddress = true
isDisabled = !bool(a.Send) || (a.Status != proton.AddressStatusEnabled)
break
}
if !isUserAddress && senderAddr != "" {
return
}
reportResult := "using sender address"
if !isCombinedMode {
reportResult = "error address not match"
}
reportAddress := ""
if senderAddr == "" {
reportAddress = " invalid"
reportResult = "error import/draft"
}
if isDisabled {
reportAddress = " disabled"
if isDraft {
reportResult = "error draft"
}
}
report := fmt.Sprintf(
"GODT-3185: %s with non-default%s address in %s mode: %s",
reportAction, reportAddress, reportMode, reportResult,
)
s.log.Warn(report)
if s.reporter != nil {
_ = s.reporter.ReportMessage(report)
}
}

View File

@ -203,3 +203,35 @@ func TestFixGODT3003Labels_Noop(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.False(t, applied) require.False(t, applied)
} }
func TestStripPlusAlias(t *testing.T) {
cases := map[string]string{
"one@three.com": "one@three.com",
"one+two@three.com": "one@three.com",
"one@three+two.com": "one@three+two.com",
"+one@three.com": "+one@three.com",
"@three.com": "@three.com",
}
for given, want := range cases {
require.Equal(t, want, stripPlusAlias(given), "input was %q", given)
}
}
func TestEqualAddresse(t *testing.T) {
cases := []struct {
a, b string
want bool
}{
{"one@three.com", "one@three.com", true},
{"one@three.com", "one+two@three.com", true},
{"OnE@thReE.com", "One@THree.com", true},
{"one@three.com", "two@three.com", false},
{"one+two@three.com", "two@three.com", false},
{"one@three.com", "one@three+two.com", false},
}
for _, c := range cases {
require.Equal(t, c.want, equalAddresses(c.a, c.b), "input was %q and %q", c.a, c.b)
}
}

View File

@ -508,6 +508,7 @@ func (s *Service) buildConnectors() (map[string]*Connector, error) {
s.sendRecorder, s.sendRecorder,
s.panicHandler, s.panicHandler,
s.telemetry, s.telemetry,
s.reporter,
s.showAllMail, s.showAllMail,
s.syncStateProvider, s.syncStateProvider,
) )
@ -525,6 +526,7 @@ func (s *Service) buildConnectors() (map[string]*Connector, error) {
s.sendRecorder, s.sendRecorder,
s.panicHandler, s.panicHandler,
s.telemetry, s.telemetry,
s.reporter,
s.showAllMail, s.showAllMail,
s.syncStateProvider, s.syncStateProvider,
) )

View File

@ -155,6 +155,7 @@ func addNewAddressSplitMode(ctx context.Context, s *Service, addrID string) erro
s.sendRecorder, s.sendRecorder,
s.panicHandler, s.panicHandler,
s.telemetry, s.telemetry,
s.reporter,
s.showAllMail, s.showAllMail,
s.syncStateProvider, s.syncStateProvider,
) )

View File

@ -46,6 +46,7 @@ func defaultMessageJobOpts() message.JobOptions {
AddExternalID: true, // Whether to include ExternalID as X-Pm-External-Id. AddExternalID: true, // Whether to include ExternalID as X-Pm-External-Id.
AddMessageDate: true, // Whether to include message time as X-Pm-Date. AddMessageDate: true, // Whether to include message time as X-Pm-Date.
AddMessageIDReference: true, // Whether to include the MessageID in References. AddMessageIDReference: true, // Whether to include the MessageID in References.
SanitizeMBOXHeaderLine: true, // Whether to ignore header line representing MBOX delimiter
} }
} }

View File

@ -55,9 +55,8 @@ type Service struct {
panicHandler async.PanicHandler panicHandler async.PanicHandler
reporter reporter.Reporter reporter reporter.Reporter
loadedUserCount int log *logrus.Entry
log *logrus.Entry tasks *async.Group
tasks *async.Group
uidValidityGenerator imap.UIDValidityGenerator uidValidityGenerator imap.UIDValidityGenerator
telemetry Telemetry telemetry Telemetry
@ -108,6 +107,16 @@ func (sm *Service) Init(ctx context.Context, group *async.Group, subscription ev
}) })
}) })
if err := sm.serveIMAP(ctx); err != nil {
sm.log.WithError(err).Error("Failed to start IMAP server on bridge start")
sm.imapListener = nil
}
if err := sm.serveSMTP(ctx); err != nil {
sm.log.WithError(err).Error("Failed to start SMTP server on bridge start")
sm.smtpListener = nil
}
return nil return nil
} }
@ -255,30 +264,16 @@ func (sm *Service) run(ctx context.Context, subscription events.Subscription) {
} }
func (sm *Service) handleLoadedUserCountChange(ctx context.Context) { func (sm *Service) handleLoadedUserCountChange(ctx context.Context) {
sm.log.Infof("Validating Listener State %v", sm.loadedUserCount) sm.log.Infof("Validating Listener State")
if sm.shouldStartServers() { if sm.imapListener == nil {
if sm.imapListener == nil { if err := sm.serveIMAP(ctx); err != nil {
if err := sm.serveIMAP(ctx); err != nil { sm.log.WithError(err).Error("Failed to start IMAP server")
sm.log.WithError(err).Error("Failed to start IMAP server")
}
} }
}
if sm.smtpListener == nil { if sm.smtpListener == nil {
if err := sm.restartSMTP(ctx); err != nil { if err := sm.restartSMTP(ctx); err != nil {
sm.log.WithError(err).Error("Failed to start SMTP server") sm.log.WithError(err).Error("Failed to start SMTP server")
}
}
} else {
if sm.imapListener != nil {
if err := sm.stopIMAPListener(ctx); err != nil {
sm.log.WithError(err).Error("Failed to stop IMAP server")
}
}
if sm.smtpListener != nil {
if err := sm.closeSMTPServer(ctx); err != nil {
sm.log.WithError(err).Error("Failed to stop SMTP server")
}
} }
} }
} }
@ -307,12 +302,7 @@ func (sm *Service) handleAddIMAPUser(ctx context.Context,
) error { ) error {
// Due to the many different error exits, performer user count change at this stage rather we split the incrementing // Due to the many different error exits, performer user count change at this stage rather we split the incrementing
// of users from the logic. // of users from the logic.
err := sm.handleAddIMAPUserImpl(ctx, connector, addrID, idProvider, syncStateProvider) return sm.handleAddIMAPUserImpl(ctx, connector, addrID, idProvider, syncStateProvider)
if err == nil {
sm.loadedUserCount++
}
return err
} }
func (sm *Service) handleAddIMAPUserImpl(ctx context.Context, func (sm *Service) handleAddIMAPUserImpl(ctx context.Context,
@ -436,8 +426,6 @@ func (sm *Service) handleRemoveIMAPUser(ctx context.Context, withData bool, idPr
return fmt.Errorf("failed to remove IMAP user ID: %w", err) return fmt.Errorf("failed to remove IMAP user ID: %w", err)
} }
} }
sm.loadedUserCount--
} }
return nil return nil
@ -542,11 +530,7 @@ func (sm *Service) restartIMAP(ctx context.Context) error {
sm.eventPublisher.PublishEvent(ctx, events.IMAPServerStopped{}) sm.eventPublisher.PublishEvent(ctx, events.IMAPServerStopped{})
} }
if sm.shouldStartServers() { return sm.serveIMAP(ctx)
return sm.serveIMAP(ctx)
}
return nil
} }
func (sm *Service) restartSMTP(ctx context.Context) error { func (sm *Service) restartSMTP(ctx context.Context) error {
@ -560,11 +544,7 @@ func (sm *Service) restartSMTP(ctx context.Context) error {
sm.smtpServer = newSMTPServer(sm.smtpAccounts, sm.smtpSettings) sm.smtpServer = newSMTPServer(sm.smtpAccounts, sm.smtpSettings)
if sm.shouldStartServers() { return sm.serveSMTP(ctx)
return sm.serveSMTP(ctx)
}
return nil
} }
func (sm *Service) serveSMTP(ctx context.Context) error { func (sm *Service) serveSMTP(ctx context.Context) error {
@ -679,8 +659,6 @@ func (sm *Service) handleSetGluonDir(ctx context.Context, newGluonDir string) er
return fmt.Errorf("failed to close IMAP: %w", err) return fmt.Errorf("failed to close IMAP: %w", err)
} }
sm.loadedUserCount = 0
if err := moveGluonCacheDir(sm.imapSettings, currentGluonDir, newGluonDir); err != nil { if err := moveGluonCacheDir(sm.imapSettings, currentGluonDir, newGluonDir); err != nil {
sm.log.WithError(err).Error("failed to move GluonCacheDir") sm.log.WithError(err).Error("failed to move GluonCacheDir")
@ -700,19 +678,13 @@ func (sm *Service) handleSetGluonDir(ctx context.Context, newGluonDir string) er
sm.imapServer = imapServer sm.imapServer = imapServer
if sm.shouldStartServers() { if err := sm.serveIMAP(ctx); err != nil {
if err := sm.serveIMAP(ctx); err != nil { return fmt.Errorf("failed to serve IMAP: %w", err)
return fmt.Errorf("failed to serve IMAP: %w", err)
}
} }
return nil return nil
} }
func (sm *Service) shouldStartServers() bool {
return sm.loadedUserCount >= 1
}
type smRequestClose struct{} type smRequestClose struct{}
type smRequestRestartIMAP struct{} type smRequestRestartIMAP struct{}

View File

@ -27,14 +27,14 @@ var ErrInvalidReturnPath = errors.New("invalid return path")
var ErrNoSuchUser = errors.New("no such user") var ErrNoSuchUser = errors.New("no such user")
var ErrTooManyErrors = errors.New("too many failed requests, please try again later") var ErrTooManyErrors = errors.New("too many failed requests, please try again later")
type ErrCanNotSendOnAddress struct { type ErrCannotSendFromAddress struct {
address string address string
} }
func NewErrCanNotSendOnAddress(address string) *ErrCanNotSendOnAddress { func NewErrCannotSendFromAddress(address string) *ErrCannotSendFromAddress {
return &ErrCanNotSendOnAddress{address: address} return &ErrCannotSendFromAddress{address: address}
} }
func (e ErrCanNotSendOnAddress) Error() string { func (e ErrCannotSendFromAddress) Error() string {
return fmt.Sprintf("can't send on address: %v", e.address) return fmt.Sprintf("cannot send from address: %v", e.address)
} }

View File

@ -103,8 +103,8 @@ func (s *Service) smtpSendMail(ctx context.Context, authID string, from string,
} }
if !fromAddr.Send || fromAddr.Status != proton.AddressStatusEnabled { if !fromAddr.Send || fromAddr.Status != proton.AddressStatusEnabled {
s.log.Errorf("Can't send emails on address: %v", fromAddr.Email) s.log.Errorf("Cannot send emails from address: %v", fromAddr.Email)
return &ErrCanNotSendOnAddress{address: fromAddr.Email} return &ErrCannotSendFromAddress{address: fromAddr.Email}
} }
// Load the user's mail settings. // Load the user's mail settings.

View File

@ -146,7 +146,7 @@ func mkdirAllClear(path string) error {
func checksum(path string) (hash string) { func checksum(path string) (hash string) {
file, err := os.Open(filepath.Clean(path)) file, err := os.Open(filepath.Clean(path))
if err != nil { if err != nil {
logrus.WithError(err).WithField("path", path).Error("Cannot open file for checksum") logrus.WithError(err).WithField("path", path).Warn("Cannot open file for checksum")
return return
} }
defer file.Close() //nolint:errcheck,gosec defer file.Close() //nolint:errcheck,gosec

View File

@ -22,9 +22,11 @@ import (
"errors" "errors"
"fmt" "fmt"
"reflect" "reflect"
"runtime"
"sync" "sync"
"time" "time"
"github.com/ProtonMail/proton-bridge/v3/internal/constants"
"github.com/docker/docker-credential-helpers/credentials" "github.com/docker/docker-credential-helpers/credentials"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
@ -216,11 +218,7 @@ func isUsable(helper credentials.Helper, err error) bool {
return false return false
} }
creds := &credentials.Credentials{ creds := getTestCredentials()
ServerURL: "bridge/check",
Username: "check",
Secret: "check",
}
if err := retry(func() error { if err := retry(func() error {
return helper.Add(creds) return helper.Add(creds)
@ -242,6 +240,23 @@ func isUsable(helper credentials.Helper, err error) bool {
return true return true
} }
func getTestCredentials() *credentials.Credentials {
// On macOS, a handful of users experience failures of the test credentials.
if runtime.GOOS == "darwin" {
return &credentials.Credentials{
ServerURL: hostURL(constants.KeyChainName) + fmt.Sprintf("/check_%v", time.Now().UTC().UnixMicro()),
Username: "", // username is ignored on macOS, it's extracted from splitting the server URL
Secret: "check",
}
}
return &credentials.Credentials{
ServerURL: "bridge/check",
Username: "check",
Secret: "check",
}
}
func retry(condition func() error) error { func retry(condition func() error) error {
var maxRetry = 5 var maxRetry = 5
for r := 0; ; r++ { for r := 0; ; r++ {

View File

@ -19,6 +19,7 @@ package message
import ( import (
"bytes" "bytes"
"fmt"
"mime" "mime"
"net/mail" "net/mail"
"strings" "strings"
@ -46,6 +47,12 @@ var (
const InternalIDDomain = `protonmail.internalid` const InternalIDDomain = `protonmail.internalid`
func BuildRFC822Into(kr *crypto.KeyRing, decrypted *DecryptedMessage, opts JobOptions, buf *bytes.Buffer) error { func BuildRFC822Into(kr *crypto.KeyRing, decrypted *DecryptedMessage, opts JobOptions, buf *bytes.Buffer) error {
if opts.SanitizeMBOXHeaderLine {
if err := sanitizeMBOXHeaderLine(decrypted); err != nil {
return fmt.Errorf("failed to sanitize MBOX header: %w", err)
}
}
switch { switch {
case len(decrypted.Msg.Attachments) > 0: case len(decrypted.Msg.Attachments) > 0:
return buildMultipartRFC822(decrypted, opts, buf) return buildMultipartRFC822(decrypted, opts, buf)
@ -560,3 +567,80 @@ func (bw *boundary) gen() string {
bw.val = algo.HashHexSHA256(bw.val) bw.val = algo.HashHexSHA256(bw.val)
return bw.val return bw.val
} }
func mboxFrom() []byte {
return []byte("From ")
}
func mboxGtFrom() []byte {
return []byte(">From ")
}
func sanitizeMBOXHeaderLine(decrypted *DecryptedMessage) error {
if decrypted == nil {
return nil
}
if decrypted.Body.Len() == 0 {
return nil
}
i := indexMBOXHeaderLine(decrypted)
for i >= 0 {
var buf bytes.Buffer
// copy until mbox line
if i > 0 {
if _, err := buf.Write(decrypted.Body.Next(i)); err != nil {
return fmt.Errorf("cannot copy first lines: %w", err)
}
}
// dump mbox line
eol := bytes.IndexRune(decrypted.Body.Bytes(), '\n')
if eol == 0 || eol == -1 {
return errors.New("cannot find end of mbox line")
}
_ = decrypted.Body.Next(eol + 1)
// copy rest
if _, err := buf.Write(decrypted.Body.Bytes()); err != nil {
return fmt.Errorf("cannot rest of message: %w", err)
}
decrypted.Body = buf
i = indexMBOXHeaderLine(decrypted)
}
return nil
}
func indexMBOXHeaderLine(decrypted *DecryptedMessage) int {
b := decrypted.Body.Bytes()
headerEnd := bytes.Index(b, []byte("\n\n"))
if headerEnd < 0 {
headerEnd = bytes.Index(b, []byte("\r\n\r\n"))
}
if headerEnd < 0 {
headerEnd = len(b)
}
for i := 0; i < headerEnd; i++ {
if i != 0 && b[i] != '\n' {
continue
}
j := 0
if i != 0 {
j = i + 1
}
if bytes.HasPrefix(b[j:], mboxFrom()) || bytes.HasPrefix(b[j:], mboxGtFrom()) {
return j
}
}
return -1
}

View File

@ -18,6 +18,7 @@
package message package message
import ( import (
"bytes"
"net/mail" "net/mail"
"os" "os"
"path/filepath" "path/filepath"
@ -1298,3 +1299,96 @@ func TestBuildComplexMIMEType(t *testing.T) {
expectContentTypeParam(`name`, is(`Cat_August_2010-4.jpeg`)). expectContentTypeParam(`name`, is(`Cat_August_2010-4.jpeg`)).
expectContentDispositionParam(`filename`, is(`Cat_August_2010-4.jpeg`)) expectContentDispositionParam(`filename`, is(`Cat_August_2010-4.jpeg`))
} }
func TestHasMBOXHeaderLine(t *testing.T) {
cases := map[string]struct {
index, indexCRLF int
}{
"From: ok\nTo: Ok": {-1, -1},
"From: ok\nTo: Ok\n\nFrom - 123": {-1, -1},
"From: ok\nTo: Ok\n\n>From - 123": {-1, -1},
">From: ok\nTo: Ok": {-1, -1},
">From: ok\nTo: Ok\n\nFrom - 123": {-1, -1},
">From: ok\nTo: Ok\n\n>From - 123": {-1, -1},
"From - 123\nFrom: ok\nTo: Ok": {0, 0},
"From - 123\nFrom: ok\nTo: Ok\n\nFrom - 123": {0, 0},
"From - 123\nFrom: ok\nTo: Ok\n\n>From - 123": {0, 0},
"From: ok\nFrom - 123\nTo: Ok": {9, 10},
"From: ok\nFrom - 123\nTo: Ok\n\nFrom - 123": {9, 10},
"From: ok\nFrom - 123\nTo: Ok\n\n>From - 123": {9, 10},
">From - 123\nFrom: ok\nTo: Ok": {0, 0},
">From - 123\nFrom: ok\nTo: Ok\n\nFrom - 123": {0, 0},
">From - 123\nFrom: ok\nTo: Ok\n\n>From - 123": {0, 0},
"From: ok\n>From - 123\nTo: Ok": {9, 10},
"From: ok\n>From - 123\nTo: Ok\n\nFrom - 123": {9, 10},
"From: ok\n>From - 123\nTo: Ok\n\n>From - 123": {9, 10},
}
test := func(t *testing.T, wantIndex int, given string, useCRLF bool) {
decrypted := &DecryptedMessage{}
if useCRLF {
decrypted.Body = *bytes.NewBufferString(strings.ReplaceAll(given, "\n", "\r\n"))
} else {
decrypted.Body = *bytes.NewBufferString(given)
}
require.Equal(t, wantIndex, indexMBOXHeaderLine(decrypted))
}
for given, want := range cases {
t.Run("LF-"+given, func(t *testing.T) { test(t, want.index, given, false) })
t.Run("CRLF-"+given, func(t *testing.T) { test(t, want.indexCRLF, given, true) })
}
}
func TestSanitizeMBOXHeaderLine(t *testing.T) {
cases := map[string]string{
"From: ok\nTo: Ok": "From: ok\nTo: Ok",
"From: ok\nTo: Ok\n\nFrom - 123": "From: ok\nTo: Ok\n\nFrom - 123",
"From: ok\nTo: Ok\n\n>From - 123": "From: ok\nTo: Ok\n\n>From - 123",
">From: ok\nTo: Ok": ">From: ok\nTo: Ok",
">From: ok\nTo: Ok\n\nFrom - 123": ">From: ok\nTo: Ok\n\nFrom - 123",
">From: ok\nTo: Ok\n\n>From - 123": ">From: ok\nTo: Ok\n\n>From - 123",
"From - 123\nFrom: ok\nTo: Ok": "From: ok\nTo: Ok",
"From - 123\nFrom: ok\nTo: Ok\n\nFrom - 123": "From: ok\nTo: Ok\n\nFrom - 123",
"From - 123\nFrom: ok\nTo: Ok\n\n>From - 123": "From: ok\nTo: Ok\n\n>From - 123",
"From: ok\nFrom - 123\nTo: Ok": "From: ok\nTo: Ok",
"From: ok\nFrom - 123\nTo: Ok\n\nFrom - 123": "From: ok\nTo: Ok\n\nFrom - 123",
"From: ok\nFrom - 123\nTo: Ok\n\n>From - 123": "From: ok\nTo: Ok\n\n>From - 123",
">From - 123\nFrom: ok\nTo: Ok": "From: ok\nTo: Ok",
">From - 123\nFrom: ok\nTo: Ok\n\nFrom - 123": "From: ok\nTo: Ok\n\nFrom - 123",
">From - 123\nFrom: ok\nTo: Ok\n\n>From - 123": "From: ok\nTo: Ok\n\n>From - 123",
"From: ok\n>From - 123\nTo: Ok": "From: ok\nTo: Ok",
"From: ok\n>From - 123\nTo: Ok\n\nFrom - 123": "From: ok\nTo: Ok\n\nFrom - 123",
"From: ok\n>From - 123\nTo: Ok\n\n>From - 123": "From: ok\nTo: Ok\n\n>From - 123",
}
test := func(t *testing.T, given, want string, useCRLF bool) {
decrypted := &DecryptedMessage{}
if useCRLF {
decrypted.Body = *bytes.NewBufferString(strings.ReplaceAll(given, "\n", "\r\n"))
want = strings.ReplaceAll(want, "\n", "\r\n")
} else {
decrypted.Body = *bytes.NewBufferString(given)
}
require.NoError(t, sanitizeMBOXHeaderLine(decrypted))
require.Equal(t, []byte(want), decrypted.Body.Bytes())
}
for given, want := range cases {
t.Run("LF"+given, func(t *testing.T) { test(t, given, want, false) })
t.Run("CRLF"+given, func(t *testing.T) { test(t, given, want, true) })
}
}

View File

@ -24,4 +24,5 @@ type JobOptions struct {
AddExternalID bool // Whether to include ExternalID as X-Pm-External-Id. AddExternalID bool // Whether to include ExternalID as X-Pm-External-Id.
AddMessageDate bool // Whether to include message time as X-Pm-Date. AddMessageDate bool // Whether to include message time as X-Pm-Date.
AddMessageIDReference bool // Whether to include the MessageID in References. AddMessageIDReference bool // Whether to include the MessageID in References.
SanitizeMBOXHeaderLine bool // Whether to ignore header line representing MBOX delimiter
} }

View File

@ -45,7 +45,7 @@ import (
"google.golang.org/grpc" "google.golang.org/grpc"
) )
var defaultVersion = semver.MustParse("3.0.6") var defaultVersion = semver.MustParse("3.10.0")
type testUser struct { type testUser struct {
name string // the test user name name string // the test user name

View File

@ -205,3 +205,30 @@ func (s *scenario) theBodyInTheResponseToIs(method, path string, value *godog.Do
return nil return nil
} }
func (s *scenario) theMessageUsedKeyForSending(address string) error {
addrID := s.t.getUserByAddress(address).getAddrID(address)
call, err := s.t.getLastCallExcludingHTTPOverride("POST", "/mail/v4/messages")
if err != nil {
return err
}
var body, want map[string]any
if err := json.Unmarshal(call.ResponseBody, &body); err != nil {
return err
}
want = map[string]any{
"Message": map[string]any{
"AddressID": addrID,
},
}
if !IsSub(body, want) {
return fmt.Errorf("have body %v, want %v", body, want)
}
return nil
}

View File

@ -0,0 +1,42 @@
Feature: IMAP client authentication with address modes
Background:
Given there exists an account with username "[user:user]" and password "password"
And the account "[user:user]" has additional address "[alias:alias]@[domain]"
And it succeeds
Scenario: IMAP client can authenticate successfully with secondary address in combine mode
Given bridge starts
And the user logs in with username "[user:user]" and password "password"
And user "[user:user]" finishes syncing
Then user "[user:user]" connects and authenticates IMAP client "1" with address "[alias:alias]@[domain]"
Scenario: IMAP client can authenticate successfully with secondary address in split mode
Given bridge starts
And the user logs in with username "[user:user]" and password "password"
And the user sets the address mode of user "[user:user]" to "split"
And user "[user:user]" finishes syncing
Then user "[user:user]" connects and authenticates IMAP client "1" with address "[alias:alias]@[domain]"
# Need to find way to setup disabled address on black
@skip-black
Scenario: IMAP client cannot authenticate successfully with disabled alias in combine mode
Given the account "[user:user]" has additional disabled address "[alias:disabled]@[domain]"
And it succeeds
Given bridge starts
And the user logs in with username "[user:user]" and password "password"
And user "[user:user]" finishes syncing
# GODT-3307 it should succeed
When user "[user:user]" connects and can not authenticate IMAP client "1" with address "[alias:disabled]@[domain]"
# Need to find way to setup disabled address on black
@skip-black
Scenario: IMAP client cannot authenticate successfully with disabled alias in split mode
Given the account "[user:user]" has additional disabled address "[alias:disabled]@[domain]"
And it succeeds
Given bridge starts
And the user logs in with username "[user:user]" and password "password"
And the user sets the address mode of user "[user:user]" to "split"
And user "[user:user]" finishes syncing
# GODT-3307 it should succeed
When user "[user:user]" connects and can not authenticate IMAP client "1" with address "[alias:disabled]@[domain]"

View File

@ -20,13 +20,6 @@ Feature: A user can authenticate an IMAP client
Scenario: IMAP client can authenticate successfully with secondary address Scenario: IMAP client can authenticate successfully with secondary address
Given user "[user:user]" connects and authenticates IMAP client "1" with address "[alias:alias]@[domain]" Given user "[user:user]" connects and authenticates IMAP client "1" with address "[alias:alias]@[domain]"
# Need to find way to setup disabled address on black
@skip-black
Scenario: IMAP client can not authenticate successfully with disable address
Given the account "[user:user2]" has additional disabled address "[alias:disabled]@[domain]"
And it succeeds
Then user "[user:user2]" connects and can not authenticate IMAP client "1" with address "[alias:disabled]@[domain]"
Scenario: IMAP client can authenticate successfully Scenario: IMAP client can authenticate successfully
When user "[user:user]" connects IMAP client "1" When user "[user:user]" connects IMAP client "1"
Then IMAP client "1" can authenticate Then IMAP client "1" can authenticate

View File

@ -57,6 +57,7 @@ Feature: IMAP create messages
And IMAP client "1" eventually sees the following messages in "All Mail": And IMAP client "1" eventually sees the following messages in "All Mail":
| from | to | subject | body | | from | to | subject | body |
| [alias:alias]@[domain] | john.doe@email.com | foo | bar | | [alias:alias]@[domain] | john.doe@email.com | foo | bar |
And bridge reports a message with "GODT-3185: import with non-default address in combined mode: using sender address"
Scenario: Imports an unrelated message to inbox Scenario: Imports an unrelated message to inbox
When IMAP client "1" appends the following messages to "INBOX": When IMAP client "1" appends the following messages to "INBOX":

View File

@ -164,6 +164,7 @@ Feature: IMAP Draft messages
And IMAP client "1" eventually sees the following messages in "Drafts": And IMAP client "1" eventually sees the following messages in "Drafts":
| to | subject | body | | to | subject | body |
| someone@example.com | Draft without From | This is a Draft without From in header | | someone@example.com | Draft without From | This is a Draft without From in header |
And bridge reports a message with "GODT-3185: draft with non-default invalid address in combined mode: error import/draft"
@regression @regression
Scenario: Only one draft in Drafts and All Mail after editing it locally multiple times Scenario: Only one draft in Drafts and All Mail after editing it locally multiple times

View File

@ -0,0 +1,45 @@
Feature: SMTP client authentication with address modes
Background:
Given there exists an account with username "[user:user]" and password "password"
And the account "[user:user]" has additional address "[alias:alias]@[domain]"
And it succeeds
Scenario: SMTP client can authenticate successfully with secondary address in combine mode
Given bridge starts
And the user logs in with username "[user:user]" and password "password"
And user "[user:user]" finishes syncing
When user "[user:user]" connects and authenticates SMTP client "1" with address "[alias:alias]@[domain]"
Then it succeeds
Scenario: SMTP client can authenticate successfully with secondary address in split mode
Given bridge starts
And the user logs in with username "[user:user]" and password "password"
And the user sets the address mode of user "[user:user]" to "split"
And user "[user:user]" finishes syncing
When user "[user:user]" connects and authenticates SMTP client "1" with address "[alias:alias]@[domain]"
Then it succeeds
# Need to find way to setup disabled address on black
@skip-black
Scenario: SMTP client can authenticate successfully with disabled alias in combine mode
Given the account "[user:user]" has additional disabled address "[alias:disabled]@[domain]"
And it succeeds
Given bridge starts
And the user logs in with username "[user:user]" and password "password"
And user "[user:user]" finishes syncing
When user "[user:user]" connects and authenticates SMTP client "1" with address "[alias:disabled]@[domain]"
Then it fails
# Need to find way to setup disabled address on black
@skip-black
Scenario: SMTP client can authenticate successfully with disabled alias in split mode
Given the account "[user:user]" has additional disabled address "[alias:disabled]@[domain]"
And it succeeds
Given bridge starts
And the user logs in with username "[user:user]" and password "password"
And the user sets the address mode of user "[user:user]" to "split"
And user "[user:user]" finishes syncing
When user "[user:user]" connects and authenticates SMTP client "1" with address "[alias:disabled]@[domain]"
Then it fails

View File

@ -20,5 +20,5 @@ Feature: SMTP wrong messages
Hello Hello
""" """
And it fails with error "Error: can't send on address: [user:disabled]@[domain]" And it fails with error "Error: cannot send from address: [user:disabled]@[domain]"

View File

@ -331,4 +331,4 @@ Feature: SMTP sending of plain messages
} }
] ]
} }
""" """

View File

@ -0,0 +1,79 @@
Feature: Address key usage during SMTP send
Background:
Given there exists an account with username "[user:user]" and password "password"
And the account "[user:user]" has additional address "[alias:alias]@[domain]"
And it succeeds
Scenario: Non-active sender in combined mode using non-active key
Given bridge starts
And the user logs in with username "[user:user]" and password "password"
And it succeeds
When user "[user:user]" connects and authenticates SMTP client "1" with address "[user:user]@[domain]"
And SMTP client "1" sends the following message from "[alias:alias]@[domain]" to "pm.bridge.qa@gmail.com":
"""
From: Bridge Test <[alias:alias]@[domain]>
To: External Bridge <pm.bridge.qa@gmail.com>
hello
"""
Then it succeeds
And the message used "[alias:alias]@[domain]" key for sending
Scenario: Non-active sender in split mode using non-active key
Given bridge starts
And the user logs in with username "[user:user]" and password "password"
And the user sets the address mode of user "[user:user]" to "split"
And user "[user:user]" finishes syncing
And it succeeds
When user "[user:user]" connects and authenticates SMTP client "1" with address "[user:user]@[domain]"
And SMTP client "1" sends the following message from "[alias:alias]@[domain]" to "pm.bridge.qa@gmail.com":
"""
From: Bridge Test <[alias:alias]@[domain]>
To: External Bridge <pm.bridge.qa@gmail.com>
hello
"""
Then it succeeds
And the message used "[alias:alias]@[domain]" key for sending
# Need to find way to setup disabled address on black
@skip-black
Scenario: Disabled sender in combined mode fails to send
Given the account "[user:user]" has additional disabled address "[user:disabled]@[domain]"
And it succeeds
And bridge starts
And the user logs in with username "[user:user]" and password "password"
And it succeeds
When user "[user:user]" connects and authenticates SMTP client "1" with address "[user:user]@[domain]"
And SMTP client "1" sends the following message from "[alias:disabled]@[domain]" to "pm.bridge.qa@gmail.com":
"""
From: Bridge Test <[alias:disabled]@[domain]>
To: External Bridge <pm.bridge.qa@gmail.com>
hello
"""
Then it fails
# Need to find way to setup disabled address on black
@skip-black
Scenario: Disabled sender in split mode fails to send
Given the account "[user:user]" has additional disabled address "[alias:disabled]@[domain]"
And it succeeds
And bridge starts
And the user logs in with username "[user:user]" and password "password"
And the user sets the address mode of user "[user:user]" to "split"
And user "[user:user]" finishes syncing
And it succeeds
When user "[user:user]" connects and authenticates SMTP client "1" with address "[alias:alias]@[domain]"
And SMTP client "1" sends the following message from "[alias:disabled]@[domain]" to "pm.bridge.qa@gmail.com":
"""
From: Bridge Test <[alias:disabled]@[domain]>
To: External Bridge <pm.bridge.qa@gmail.com>
hello
"""
Then it fails

View File

@ -38,6 +38,8 @@ func (s *scenario) steps(ctx *godog.ScenarioContext) {
ctx.Step(`^the network port range (\d+)-(\d+) is busy$`, s.networkPortRangeIsBusy) ctx.Step(`^the network port range (\d+)-(\d+) is busy$`, s.networkPortRangeIsBusy)
ctx.Step(`^bridge IMAP port is (\d+)`, s.bridgeIMAPPortIs) ctx.Step(`^bridge IMAP port is (\d+)`, s.bridgeIMAPPortIs)
ctx.Step(`^bridge SMTP port is (\d+)`, s.bridgeSMTPPortIs) ctx.Step(`^bridge SMTP port is (\d+)`, s.bridgeSMTPPortIs)
ctx.Step(`^the message used "([^"]*)" key for sending$`, s.theMessageUsedKeyForSending)
// ==== SETUP ==== // ==== SETUP ====
ctx.Step(`^there exists an account with username "([^"]*)" and password "([^"]*)"$`, s.thereExistsAnAccountWithUsernameAndPassword) ctx.Step(`^there exists an account with username "([^"]*)" and password "([^"]*)"$`, s.thereExistsAnAccountWithUsernameAndPassword)
ctx.Step(`^there exists a disabled account with username "([^"]*)" and password "([^"]*)"$`, s.thereExistsAnAccountWithUsernameAndPasswordWithDisablePrimary) ctx.Step(`^there exists a disabled account with username "([^"]*)" and password "([^"]*)"$`, s.thereExistsAnAccountWithUsernameAndPasswordWithDisablePrimary)

View File

@ -28,7 +28,6 @@ import (
"github.com/ProtonMail/go-proton-api" "github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/gopenpgp/v2/crypto" "github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/ProtonMail/proton-bridge/v3/internal/bridge" "github.com/ProtonMail/proton-bridge/v3/internal/bridge"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/vault" "github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/pkg/algo" "github.com/ProtonMail/proton-bridge/v3/pkg/algo"
"github.com/bradenaw/juniper/iterator" "github.com/bradenaw/juniper/iterator"
@ -330,25 +329,14 @@ func (s *scenario) drafAtIndexWasMovedToTrashForAddressOfAccount(draftIndex int,
} }
func (s *scenario) userLogsInWithUsernameAndPassword(username, password string) error { func (s *scenario) userLogsInWithUsernameAndPassword(username, password string) error {
smtpEvtCh, cancelSMTP := s.t.bridge.GetEvents(events.SMTPServerReady{})
defer cancelSMTP()
imapEvtCh, cancelIMAP := s.t.bridge.GetEvents(events.IMAPServerReady{})
defer cancelIMAP()
userID, err := s.t.bridge.LoginFull(context.Background(), username, []byte(password), nil, nil) userID, err := s.t.bridge.LoginFull(context.Background(), username, []byte(password), nil, nil)
if err != nil { if err != nil {
s.t.pushError(err) s.t.pushError(err)
} else { } else {
// We need to wait for server to be up or we won't be able to connect. It should only happen once to avoid // We need to wait for server to be up or we won't be able to connect. It should only happen once to avoid
// blocking on multiple Logins. // blocking on multiple Logins.
if !s.t.imapServerStarted { s.t.imapServerStarted = true
<-imapEvtCh s.t.smtpServerStarted = true
s.t.imapServerStarted = true
}
if !s.t.smtpServerStarted {
<-smtpEvtCh
s.t.smtpServerStarted = true
}
if userID != s.t.getUserByName(username).getUserID() { if userID != s.t.getUserByName(username).getUserID() {
return errors.New("user ID mismatch") return errors.New("user ID mismatch")
@ -366,25 +354,14 @@ func (s *scenario) userLogsInWithUsernameAndPassword(username, password string)
} }
func (s *scenario) userLogsInWithAliasAddressAndPassword(alias, password string) error { func (s *scenario) userLogsInWithAliasAddressAndPassword(alias, password string) error {
smtpEvtCh, cancelSMTP := s.t.bridge.GetEvents(events.SMTPServerReady{})
defer cancelSMTP()
imapEvtCh, cancelIMAP := s.t.bridge.GetEvents(events.IMAPServerReady{})
defer cancelIMAP()
userID, err := s.t.bridge.LoginFull(context.Background(), s.t.getUserByAddress(alias).getName(), []byte(password), nil, nil) userID, err := s.t.bridge.LoginFull(context.Background(), s.t.getUserByAddress(alias).getName(), []byte(password), nil, nil)
if err != nil { if err != nil {
s.t.pushError(err) s.t.pushError(err)
} else { } else {
// We need to wait for server to be up or we won't be able to connect. It should only happen once to avoid // We need to wait for server to be up or we won't be able to connect. It should only happen once to avoid
// blocking on multiple Logins. // blocking on multiple Logins.
if !s.t.imapServerStarted { s.t.imapServerStarted = true
<-imapEvtCh s.t.smtpServerStarted = true
s.t.imapServerStarted = true
}
if !s.t.smtpServerStarted {
<-smtpEvtCh
s.t.smtpServerStarted = true
}
if userID != s.t.getUserByAddress(alias).getUserID() { if userID != s.t.getUserByAddress(alias).getUserID() {
return errors.New("user ID mismatch") return errors.New("user ID mismatch")

View File

@ -0,0 +1,20 @@
@@ -0,0 +1,10 @@
# Vault Editor
Bridge uses an encrypted vault to store persistent data. One of the parameters stored in this vault is the roll factor (between 0.0 and 1.0)
It can be built with `make vault-editor` in the bridge source code root directory.
Example usage:
Setting the rollout value:
```bash
$ ./bridge-rollout set -v=0.81
0.81
```
Note that the provided value will be clamped between 0 and 1.
```bash
$ ./bridge-rollout get
0.81
```

View File

@ -0,0 +1,85 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package main
import (
"fmt"
"os"
"github.com/ProtonMail/gluon/async"
"github.com/ProtonMail/proton-bridge/v3/internal/app"
"github.com/ProtonMail/proton-bridge/v3/internal/locations"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/pkg/keychain"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
func main() {
logrus.SetLevel(logrus.ErrorLevel)
app := cli.NewApp()
app.Commands = []*cli.Command{
{
Name: "get",
Action: getRollout,
Usage: "get the bridge rollout value",
},
{
Name: "set",
Action: setRollout,
Flags: []cli.Flag{
&cli.Float64Flag{
Name: "value",
Usage: "the rollout value",
Required: true,
Aliases: []string{"v"},
},
},
Usage: "set the bridge rollout value",
},
}
if err := app.Run(os.Args); err != nil {
logrus.Fatal(err)
}
}
func getRollout(_ *cli.Context) error {
return app.WithLocations(func(locations *locations.Locations) error {
return app.WithKeychainList(async.NoopPanicHandler{}, func(keychains *keychain.List) error {
return app.WithVault(locations, keychains, async.NoopPanicHandler{}, func(vault *vault.Vault, insecure, corrupt bool) error {
fmt.Println(vault.GetUpdateRollout())
return nil
})
})
})
}
func setRollout(c *cli.Context) error {
return app.WithLocations(func(locations *locations.Locations) error {
return app.WithKeychainList(async.NoopPanicHandler{}, func(keychains *keychain.List) error {
return app.WithVault(locations, keychains, async.NoopPanicHandler{}, func(vault *vault.Vault, insecure, corrupt bool) error {
clamped := max(0.0, min(1.0, c.Float64("value")))
if err := vault.SetUpdateRollout(clamped); err != nil {
return err
}
return getRollout(c)
})
})
})
}

View File

@ -28,6 +28,12 @@ main(){
jq -r '.finding | select( (.osv != null) and (.trace[0].function != null) ) | .osv ' < vulns.json > vulns_osv_ids.txt jq -r '.finding | select( (.osv != null) and (.trace[0].function != null) ) | .osv ' < vulns.json > vulns_osv_ids.txt
ignore GO-2023-2328 "GODT-3124 RESTY race condition" ignore GO-2023-2328 "GODT-3124 RESTY race condition"
ignore GO-2024-2598 "BRIDGE-16 Update Go to 1.21.9"
ignore GO-2024-2599 "BRIDGE-16 Update Go to 1.21.9"
ignore GO-2024-2600 "BRIDGE-16 Update Go to 1.21.9"
ignore GO-2024-2609 "BRIDGE-16 Update Go to 1.21.9"
ignore GO-2024-2610 "BRIDGE-16 Update Go to 1.21.9"
ignore GO-2024-2687 "BRIDGE-16 Update Go to 1.21.9"
has_vulns has_vulns