Compare commits

..

55 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
8e49c84a12 chore: changelog update. 2024-03-06 08:19:13 +01:00
754d80d097 feat(GODT-3193): assume text content type on attachments. 2024-03-01 15:25:37 +00:00
63e272e270 feat(GODT-3193): preserve attachment encoding. 2024-03-01 15:25:37 +00:00
54859a34b2 fix(GODT-3290): fix test failing because of leap day. 2024-03-01 10:56:24 +01:00
9b1feed68b feat(GODT-3214): encrypt only with primary key. 2024-02-28 13:42:09 +00:00
c9b6cc162b feat(GODT-3199): add package log field. 2024-02-27 13:07:37 +01:00
bf3c90b8e9 test(GODT-1602): rebased GPA changes. 2024-02-26 16:56:52 +01:00
8d63fb2301 feat(GODT-2662): enable cache on darwin tart. 2024-02-23 10:33:26 +01:00
7953306cc8 feat(GODT-2662): use tart runner for darwin jobs. 2024-02-23 10:00:47 +01:00
37352d44d2 test(GODT-1602): run integration tests against black 🖤 2024-02-19 10:43:35 +00:00
2a1aeb208d test(GODT-3257): quad9 provider test not working on CI. 2024-02-19 10:06:02 +01:00
94fbe260e4 test(GODT-3220): Fix linting issues by deleting a function
-Deleted a function that was no longer used

GODT-3220
2024-02-14 08:57:48 +01:00
6d4937222e test(GODT-3220): Rollback to a test scenario for logging in with an alias address
-Added test scenario for logging in with an alias address

GODT-3220
2024-02-13 10:56:23 +00:00
e33bad7bf1 test(GODT-3220): Add test scenario for sending an HTML msg with public key and multiple attachments to Internal
-Added test scenario for sending an HTML msg with public key and multiple attachments to Internal
- Verified the message on receipient's side

GODT-3220
2024-02-13 10:56:23 +00:00
70fdc91aff test(GODT-3220): Add test scenario for sending a message to multiple bcc accounts
- Added test scenario for sending a message to two bcc accounts
- Verified on recipients' side that the message is received

GODT-3220
2024-02-13 10:56:23 +00:00
bde8e45b37 test(GODT-3220): Add test scenarios for loging in with an alias address
-Added test scenarios for logging in with an alias address and logging in with an alias address that no longer exists

GODT-3220
2024-02-13 10:56:23 +00:00
6cb2d944d0 test(GODT-3220): Add test scenarios for logining in with alias address and loging in with an alias address
-Added a test scenario for logging in with an alias address
-Added a test scenario for logging in with alias address that no longer exists

GODT-3220
2024-02-13 10:56:23 +00:00
cf0f59afc0 test(GODT-3220): Add scenario cannot login with deleted alias 2024-02-13 10:56:23 +00:00
65d8fbbf31 test: keep deleted address in test suite 2024-02-13 10:56:23 +00:00
d919c0accf test(GODT-3220): Add step definition for logging in with alias address
GODT-3220
2024-02-13 10:56:23 +00:00
0ca07066db test(GODT-3220): Create function for getting the test user by address
GODT-3220
2024-02-13 10:56:23 +00:00
152 changed files with 4691 additions and 2243 deletions

View File

@ -3,6 +3,48 @@
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
### Added
* GODT-3199: Add package log field.
* GODT-3220: Add more test scenarios.
### Changed
* GODT-3193: Preserve attachment encoding.
* GODT-3214: Encrypt only with primary key.
* GODT-2662: Use tart runner for darwin jobs.
* GODT-1602: Test: run integration tests against black 🖤.
* GODT-3257: Test: quad9 provider test not working on CI.
### Fixed
* GODT-3290: Fix test failing because of leap day.
## Ypsilon Bridge 3.9.1
### Fixed
@ -43,6 +85,12 @@ Changelog [format](http://keepachangelog.com/en/1.0.0/)
* GODT-3188: Happy new year.
## Xikou Bridge 3.8.2
### Fixed
* GODT-3235: Update bridge update key.
## Xikou Bridge 3.8.1
### Added

View File

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

View File

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

View File

@ -2,23 +2,41 @@
---
.env-windows:
extends:
- .image-windows-virt-build
before_script:
- export BRIDGE_SYNC_FORCE_MINIMUM_SPEC=1
- !reference [.before-script-windows-aws-build, before_script]
- !reference [.before-script-windows-virt-build, before_script]
- !reference [.before-script-git-config, before_script]
- git config --global safe.directory '*'
- git status --porcelain
cache: {}
tags:
- windows-bridge
- mkdir -p .cache/bin
- export PATH=$(pwd)/.cache/bin:$PATH
- export GOPATH="$CI_PROJECT_DIR/.cache"
variables:
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:
extends:
- .image-darwin-build
before_script:
- export BRIDGE_SYNC_FORCE_MINIMUM_SPEC=1
- !reference [.before-script-darwin-build, before_script]
cache: {}
tags:
- macos-m1-bridge
- !reference [.before-script-darwin-tart-build, before_script]
- !reference [.before-script-git-config, before_script]
- mkdir -p .cache/bin
- export PATH=$(pwd)/.cache/bin:$PATH
- export GOPATH="$CI_PROJECT_DIR/.cache"
variables:
BRIDGE_SYNC_FORCE_MINIMUM_SPEC: 1
VCPKG_DEFAULT_BINARY_CACHE: ${CI_PROJECT_DIR}/.cache
cache:
key: darwin-go-and-vcpkg
paths:
- .cache
when: 'always'
.env-linux-build:
extends:

View File

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

View File

@ -19,8 +19,15 @@ package main
import (
"os"
"runtime"
"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/bradenaw/juniper/xslices"
)
@ -43,5 +50,72 @@ import (
*/
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/pkg/errors"
"github.com/sirupsen/logrus"
"golang.org/x/exp/slices"
"golang.org/x/sys/execabs"
)
@ -53,9 +54,12 @@ const (
FlagCLIShort = "c"
FlagNonInteractive = "noninteractive"
FlagNonInteractiveShort = "n"
FlagLauncher = "--launcher"
FlagWait = "--wait"
FlagSessionID = "--session-id"
FlagLauncher = "launcher"
FlagWait = "wait"
FlagSessionID = "session-id"
HyphenatedFlagLauncher = "--" + FlagLauncher
HyphenatedFlagWait = "--" + FlagWait
HyphenatedFlagSessionID = "--" + FlagSessionID
)
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.Stdout = os.Stdout
@ -173,19 +177,14 @@ func main() { //nolint:funlen
// appendLauncherPath add launcher path if missing.
func appendLauncherPath(path string, args []string) []string {
if !sliceContains(args, FlagLauncher) {
if !slices.Contains(args, HyphenatedFlagLauncher) {
res := append([]string{}, args...)
res = append(res, FlagLauncher, path)
res = append(res, HyphenatedFlagLauncher, path)
return res
}
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.
func inCLIMode(args []string) bool {
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.
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.
@ -211,7 +215,7 @@ func findAndStripWait(args []string) ([]string, bool, []string) {
hasFlag := false
values := make([]string, 0)
for k, v := range res {
if v != FlagWait {
if v != HyphenatedFlagWait {
continue
}
if k+1 >= len(res) {
@ -222,7 +226,7 @@ func findAndStripWait(args []string) ([]string, bool, []string) {
}
if hasFlag {
res, _ = findAndStrip(res, FlagWait)
res, _ = findAndStrip(res, HyphenatedFlagWait)
for _, v := range values {
res, _ = findAndStrip(res, v)
}
@ -230,6 +234,23 @@ func findAndStripWait(args []string) ([]string, bool, []string) {
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(
name string,
ver *versioner.Versioner,

View File

@ -20,61 +20,62 @@ package main
import (
"testing"
"github.com/bradenaw/juniper/xslices"
"github.com/ProtonMail/proton-bridge/v3/internal/logging"
"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) {
list := []string{"a", "b", "c", "c", "b", "c"}
result, found := findAndStrip(list, "a")
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")
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")
assert.True(t, found)
assert.True(t, xslices.Equal(result, []string{}))
assert.Equal(t, result, []string{})
result, found = findAndStrip(list, "A")
assert.False(t, found)
assert.True(t, xslices.Equal(result, list))
assert.Equal(t, result, list)
result, found = findAndStrip([]string{}, "a")
assert.False(t, found)
assert.True(t, xslices.Equal(result, []string{}))
assert.Equal(t, result, []string{})
}
func TestFindAndStripWait(t *testing.T) {
result, found, values := findAndStripWait([]string{"a", "b", "c"})
assert.False(t, found)
assert.True(t, xslices.Equal(result, []string{"a", "b", "c"}))
assert.True(t, xslices.Equal(values, []string{}))
assert.Equal(t, result, []string{"a", "b", "c"})
assert.Equal(t, values, []string{})
result, found, values = findAndStripWait([]string{"a", "--wait", "b"})
assert.True(t, found)
assert.True(t, xslices.Equal(result, []string{"a"}))
assert.True(t, xslices.Equal(values, []string{"b"}))
assert.Equal(t, result, []string{"a"})
assert.Equal(t, values, []string{"b"})
result, found, values = findAndStripWait([]string{"a", "--wait", "b", "--wait", "c"})
assert.True(t, found)
assert.True(t, xslices.Equal(result, []string{"a"}))
assert.True(t, xslices.Equal(values, []string{"b", "c"}))
assert.Equal(t, result, []string{"a"})
assert.Equal(t, values, []string{"b", "c"})
result, found, values = findAndStripWait([]string{"a", "--wait", "b", "--wait", "c", "--wait", "d"})
assert.True(t, found)
assert.True(t, xslices.Equal(result, []string{"a"}))
assert.True(t, xslices.Equal(values, []string{"b", "c", "d"}))
assert.Equal(t, result, []string{"a"})
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

4
go.mod
View File

@ -5,9 +5,9 @@ go 1.21
require (
github.com/0xAX/notificator v0.0.0-20220220101646-ee9b8921e557
github.com/Masterminds/semver/v3 v3.2.0
github.com/ProtonMail/gluon v0.17.1-0.20240102132144-89b40fb6fe7e
github.com/ProtonMail/gluon v0.17.1-0.20240227105633-3734c7694bcd
github.com/ProtonMail/go-autostart v0.0.0-20210130080809-00ed301c8e9a
github.com/ProtonMail/go-proton-api v0.4.1-0.20231130083229-e8aa47d7a366
github.com/ProtonMail/go-proton-api v0.4.1-0.20240405124415-8f966ca60436
github.com/ProtonMail/gopenpgp/v2 v2.7.4-proton
github.com/PuerkitoBio/goquery v1.8.1
github.com/abiosoft/ishell v2.0.0+incompatible

10
go.sum
View File

@ -27,8 +27,8 @@ github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAE
github.com/ProtonMail/bcrypt v0.0.0-20210511135022-227b4adcab57/go.mod h1:HecWFHognK8GfRDGnFQbW/LiV7A3MX3gZVs45vk5h8I=
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf h1:yc9daCCYUefEs69zUkSzubzjBbL+cmOXgnmt9Fyd9ug=
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf/go.mod h1:o0ESU9p83twszAU8LBeJKFAAMX14tISa0yk4Oo5TOqo=
github.com/ProtonMail/gluon v0.17.1-0.20240102132144-89b40fb6fe7e h1:DR97ydcuS4/EjTTCkp7F9IRCi+ykD1UoAP7UBFtEcRA=
github.com/ProtonMail/gluon v0.17.1-0.20240102132144-89b40fb6fe7e/go.mod h1:Og5/Dz1MiGpCJn51XujZwxiLG7WzvvjE5PRpZBQmAHo=
github.com/ProtonMail/gluon v0.17.1-0.20240227105633-3734c7694bcd h1:AjJsf5xQGmZPg6GLn+wB+eBoGRopJlG70lQBfSyfX+M=
github.com/ProtonMail/gluon v0.17.1-0.20240227105633-3734c7694bcd/go.mod h1:Og5/Dz1MiGpCJn51XujZwxiLG7WzvvjE5PRpZBQmAHo=
github.com/ProtonMail/go-autostart v0.0.0-20210130080809-00ed301c8e9a h1:D+aZah+k14Gn6kmL7eKxoo/4Dr/lK3ChBcwce2+SQP4=
github.com/ProtonMail/go-autostart v0.0.0-20210130080809-00ed301c8e9a/go.mod h1:oTGdE7/DlWIr23G0IKW3OXK9wZ5Hw1GGiaJFccTvZi4=
github.com/ProtonMail/go-crypto v0.0.0-20230321155629-9a39f2531310/go.mod h1:8TI4H3IbrackdNgv+92dI+rhpCaLqM0IfpgCgenFvRE=
@ -38,8 +38,10 @@ github.com/ProtonMail/go-message v0.13.1-0.20230526094639-b62c999c85b7 h1:+j+Kd/
github.com/ProtonMail/go-message v0.13.1-0.20230526094639-b62c999c85b7/go.mod h1:NBAn21zgCJ/52WLDyed18YvYFm5tEoeDauubFqLokM4=
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k=
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.20231130083229-e8aa47d7a366 h1:W9P5GdDnuGkB3tbzKnXmUrTjIs6zk/K+4lpPTWzsoRE=
github.com/ProtonMail/go-proton-api v0.4.1-0.20231130083229-e8aa47d7a366/go.mod h1:t+hb0BfkmZ9fpvzVRpHC7limoowym6ln/j0XL9a8DDw=
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.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/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/ProtonMail/go-srp v0.0.7 h1:Sos3Qk+th4tQR64vsxGIxYpN3rdnG9Wf9K4ZloC1JrI=

View File

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

View File

@ -43,7 +43,7 @@ import (
// nolint:gosec
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()
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 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

View File

@ -40,7 +40,7 @@ func defaultAPIOptions(
proton.WithAppVersion(constants.AppVersion(version.Original())),
proton.WithCookieJar(cookieJar),
proton.WithTransport(transport),
proton.WithLogger(logrus.StandardLogger()),
proton.WithLogger(logrus.WithField("pkg", "gpa/client")),
proton.WithPanicHandler(panicHandler),
}
}

View File

@ -132,6 +132,8 @@ type Bridge struct {
syncService *syncservice.Service
}
var logPkg = logrus.WithField("pkg", "bridge") //nolint:gochecknoglobals
// New creates a new bridge.
func New(
locator Locator, // the locator to provide paths to store data
@ -322,7 +324,7 @@ func (bridge *Bridge) init(tlsReporter TLSReporter) error {
// Handle connection up/down events.
bridge.api.AddStatusObserver(func(status proton.Status) {
logrus.Info("API status changed: ", status)
logPkg.Info("API status changed: ", status)
switch {
case status == proton.StatusUp:
@ -337,7 +339,7 @@ func (bridge *Bridge) init(tlsReporter TLSReporter) error {
// If any call returns a bad version code, we need to update.
bridge.api.AddErrorHandler(proton.AppVersionBadCode, func() {
logrus.Warn("App version is bad")
logPkg.Warn("App version is bad")
bridge.publish(events.UpdateForced{})
})
@ -350,7 +352,7 @@ func (bridge *Bridge) init(tlsReporter TLSReporter) error {
// Log all manager API requests (client requests are logged separately).
bridge.api.AddPostRequestHook(func(_ *resty.Client, r *resty.Response) error {
if _, ok := proton.ClientIDFromContext(r.Request.Context()); !ok {
logrus.Infof("[MANAGER] %v: %v %v", r.Status(), r.Request.Method, r.Request.URL)
logrus.WithField("pkg", "gpa/manager").Infof("%v: %v %v", r.Status(), r.Request.Method, r.Request.URL)
}
return nil
@ -359,7 +361,7 @@ func (bridge *Bridge) init(tlsReporter TLSReporter) error {
// Publish a TLS issue event if a TLS issue is encountered.
bridge.tasks.Once(func(ctx context.Context) {
async.RangeContext(ctx, tlsReporter.GetTLSIssueCh(), func(struct{}) {
logrus.Warn("TLS issue encountered")
logPkg.Warn("TLS issue encountered")
bridge.publish(events.TLSIssue{})
})
})
@ -367,7 +369,7 @@ func (bridge *Bridge) init(tlsReporter TLSReporter) error {
// Publish a raise event if the focus service is called.
bridge.tasks.Once(func(ctx context.Context) {
async.RangeContext(ctx, bridge.focusService.GetRaiseCh(), func(struct{}) {
logrus.Info("Focus service requested raise")
logPkg.Info("Focus service requested raise")
bridge.publish(events.Raise{})
})
})
@ -375,7 +377,7 @@ func (bridge *Bridge) init(tlsReporter TLSReporter) error {
// Handle any IMAP events that are forwarded to the bridge from gluon.
bridge.tasks.Once(func(ctx context.Context) {
async.RangeContext(ctx, bridge.imapEventCh, func(event imapEvents.Event) {
logrus.WithField("event", fmt.Sprintf("%T", event)).Debug("Received IMAP event")
logPkg.WithField("event", fmt.Sprintf("%T", event)).Debug("Received IMAP event")
bridge.handleIMAPEvent(event)
})
})
@ -383,7 +385,7 @@ func (bridge *Bridge) init(tlsReporter TLSReporter) error {
// Attempt to load users from the vault when triggered.
bridge.goLoad = bridge.tasks.Trigger(func(ctx context.Context) {
if err := bridge.loadUsers(ctx); err != nil {
logrus.WithError(err).Error("Failed to load users")
logPkg.WithError(err).Error("Failed to load users")
if netErr := new(proton.NetError); !errors.As(err, &netErr) {
sentry.ReportError(bridge.reporter, "Failed to load users", err)
}
@ -396,7 +398,7 @@ func (bridge *Bridge) init(tlsReporter TLSReporter) error {
// Check for updates when triggered.
bridge.goUpdate = bridge.tasks.PeriodicOrTrigger(constants.UpdateCheckInterval, 0, func(ctx context.Context) {
logrus.Info("Checking for updates")
logPkg.Info("Checking for updates")
version, err := bridge.updater.GetVersionInfo(ctx, bridge.api, bridge.vault.GetUpdateChannel())
if err != nil {
@ -434,7 +436,7 @@ func (bridge *Bridge) GetErrors() []error {
}
func (bridge *Bridge) Close(ctx context.Context) {
logrus.Info("Closing bridge")
logPkg.Info("Closing bridge")
// Stop heart beat before closing users.
bridge.heartbeat.stop()
@ -448,7 +450,7 @@ func (bridge *Bridge) Close(ctx context.Context) {
// Close the servers
if err := bridge.serverManager.CloseServers(ctx); err != nil {
logrus.WithError(err).Error("Failed to close servers")
logPkg.WithError(err).Error("Failed to close servers")
}
bridge.syncService.Close()
@ -474,12 +476,12 @@ func (bridge *Bridge) publish(event events.Event) {
bridge.watchersLock.RLock()
defer bridge.watchersLock.RUnlock()
logrus.WithField("event", event).Debug("Publishing event")
logPkg.WithField("event", event).Debug("Publishing event")
for _, watcher := range bridge.watchers {
if watcher.IsWatching(event) {
if ok := watcher.Send(event); !ok {
logrus.WithField("event", event).Warn("Failed to send event to watcher")
logPkg.WithField("event", event).Warn("Failed to send event to watcher")
}
}
}
@ -512,13 +514,13 @@ func (bridge *Bridge) remWatcher(watcher *watcher.Watcher[events.Event]) {
}
func (bridge *Bridge) onStatusUp(_ context.Context) {
logrus.Info("Handling API status up")
logPkg.Info("Handling API status up")
bridge.goLoad()
}
func (bridge *Bridge) onStatusDown(ctx context.Context) {
logrus.Info("Handling API status down")
logPkg.Info("Handling API status down")
for backoff := time.Second; ; backoff = min(backoff*2, 30*time.Second) {
select {
@ -526,10 +528,10 @@ func (bridge *Bridge) onStatusDown(ctx context.Context) {
return
case <-time.After(backoff):
logrus.Info("Pinging API")
logPkg.Info("Pinging API")
if err := bridge.api.Ping(ctx); err != nil {
logrus.WithError(err).Warn("Ping failed, API is still unreachable")
logPkg.WithError(err).Warn("Ping failed, API is still unreachable")
} else {
return
}

View File

@ -184,20 +184,11 @@ func TestBridge_UserAgent_Persistence(t *testing.T) {
require.NoError(t, err)
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()
require.Contains(t, currentUserAgent, useragent.DefaultUserAgent)
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()))
require.NoError(t, err)
defer func() { _ = imapClient.Logout() }()
@ -235,21 +226,12 @@ func TestBridge_UserAgentFromUnknownClient(t *testing.T) {
require.NoError(t, err)
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()
require.Contains(t, currentUserAgent, useragent.DefaultUserAgent)
userID, err := b.LoginFull(context.Background(), username, password, nil, nil)
require.NoError(t, err)
imapWaiter.Wait()
smtpWaiter.Wait()
imapClient, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, b.GetIMAPPort()))
require.NoError(t, err)
defer func() { _ = imapClient.Logout() }()
@ -274,21 +256,12 @@ func TestBridge_UserAgentFromSMTPClient(t *testing.T) {
require.NoError(t, err)
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()
require.Contains(t, currentUserAgent, useragent.DefaultUserAgent)
userID, err := b.LoginFull(context.Background(), username, password, nil, nil)
require.NoError(t, err)
imapWaiter.Wait()
smtpWaiter.Wait()
client, err := smtp.Dial(net.JoinHostPort(constants.Host, fmt.Sprint(b.GetSMTPPort())))
require.NoError(t, err)
defer client.Close() //nolint:errcheck
@ -333,17 +306,8 @@ func TestBridge_UserAgentFromIMAPID(t *testing.T) {
require.NoError(t, err)
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)))
imapWaiter.Wait()
smtpWaiter.Wait()
imapClient, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, b.GetIMAPPort()))
require.NoError(t, err)
defer func() { _ = imapClient.Logout() }()
@ -715,21 +679,12 @@ func TestBridge_InitGluonDirectory(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) {
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{}))
defer done()
_, err := bridge.LoginFull(ctx, username, password, nil, nil)
require.NoError(t, err)
imapWaiter.Wait()
smtpWaiter.Wait()
imapClient, err := eventuallyDial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetIMAPPort())))
require.NoError(t, err)
@ -757,12 +712,6 @@ func TestBridge_ChangeCacheDirectory(t *testing.T) {
configDir, err := b.GetGluonDataDir()
require.NoError(t, err)
imapWaiter := waitForIMAPServerReady(b)
defer imapWaiter.Done()
smtpWaiter := waitForSMTPServerReady(b)
defer smtpWaiter.Done()
// Login the user.
syncCh, done := chToType[events.Event, events.SyncFinished](b.GetEvents(events.SyncFinished{}))
defer done()
@ -796,9 +745,6 @@ func TestBridge_ChangeCacheDirectory(t *testing.T) {
require.NoError(t, err)
require.True(t, info.State == bridge.Connected)
imapWaiter.Wait()
smtpWaiter.Wait()
client, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, b.GetIMAPPort()))
require.NoError(t, err)
require.NoError(t, client.Login(info.Addresses[0], string(info.BridgePass)))

View File

@ -34,7 +34,7 @@ import (
// ConfigureAppleMail configures Apple Mail for the given userID and address.
// If configuring Apple Mail for Catalina or newer, it ensures Bridge is using SSL.
func (bridge *Bridge) ConfigureAppleMail(ctx context.Context, userID, address string) error {
logrus.WithFields(logrus.Fields{
logPkg.WithFields(logrus.Fields{
"userID": userID,
"address": logging.Sensitive(address),
}).Info("Configuring Apple Mail")

View File

@ -65,7 +65,11 @@ func (bridge *Bridge) CheckClientState(ctx context.Context, checkFlags bool, pro
if progressCB != nil {
progressCB(fmt.Sprintf("Checking state for user %v", usr.Name()))
}
log := logrus.WithField("user", usr.Name()).WithField("diag", "state-check")
log := logrus.WithFields(logrus.Fields{
"pkg": "bridge/debug",
"user": usr.Name(),
"diag": "state-check",
})
log.Debug("Retrieving all server metadata")
meta, err := usr.GetDiagnosticMetadata(ctx)
if err != nil {
@ -280,7 +284,7 @@ func clientGetMessageIDs(client *goimapclient.Client, mailbox string) (map[strin
internalID, ok := header.GetChecked("X-Pm-Internal-Id")
if !ok {
logrus.Errorf("Message %v does not have internal id", internalID)
logrus.WithField("pkg", "bridge/debug").Errorf("Message %v does not have internal id", internalID)
continue
}

View File

@ -64,9 +64,6 @@ func TestBridge_HandleDraftsSendFromOtherClient(t *testing.T) {
// The initial user should be fully synced.
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{}))
defer done()
@ -74,7 +71,6 @@ func TestBridge_HandleDraftsSendFromOtherClient(t *testing.T) {
require.NoError(t, err)
require.Equal(t, userID, (<-syncCh).UserID)
waiter.Wait()
info, err := b.GetUserInfo(userID)
require.NoError(t, err)

View File

@ -97,7 +97,7 @@ func (h *heartBeatState) start() {
h.taskStarted = true
h.task.PeriodicOrTrigger(h.taskInterval, 0, func(ctx context.Context) {
logrus.Debug("Checking for heartbeat")
logrus.WithField("pkg", "bridge/heartbeat").Debug("Checking for heartbeat")
h.TrySending(ctx)
})
@ -135,7 +135,7 @@ func (bridge *Bridge) SendHeartbeat(ctx context.Context, heartbeat *telemetry.He
if err := bridge.reporter.ReportMessageWithContext("Cannot parse heartbeat data.", reporter.Context{
"error": err,
}); err != nil {
logrus.WithError(err).Error("Failed to parse heartbeat data.")
logrus.WithField("pkg", "bridge/heartbeat").WithError(err).Error("Failed to parse heartbeat data.")
}
return false
}

View File

@ -35,10 +35,12 @@ func (bridge *Bridge) restartIMAP(ctx context.Context) error {
}
func (bridge *Bridge) handleIMAPEvent(event imapEvents.Event) {
log := logrus.WithField("pkg", "bridge/event/imap")
switch event := event.(type) {
case imapEvents.UserAdded:
for labelID, count := range event.Counts {
logrus.WithFields(logrus.Fields{
log.WithFields(logrus.Fields{
"gluonID": event.UserID,
"labelID": labelID,
"count": count,
@ -46,7 +48,7 @@ func (bridge *Bridge) handleIMAPEvent(event imapEvents.Event) {
}
case imapEvents.IMAPID:
logrus.WithFields(logrus.Fields{
log.WithFields(logrus.Fields{
"sessionID": event.SessionID,
"name": event.IMAPID.Name,
"version": event.IMAPID.Version,
@ -57,7 +59,7 @@ func (bridge *Bridge) handleIMAPEvent(event imapEvents.Event) {
}
case imapEvents.LoginFailed:
logrus.WithFields(logrus.Fields{
log.WithFields(logrus.Fields{
"sessionID": event.SessionID,
"username": event.Username,
"pkg": "imap",

View File

@ -46,17 +46,12 @@ func TestBridge_Send(t *testing.T) {
require.NoError(t, err)
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)
require.NoError(t, err)
recipientUserID, err := bridge.LoginFull(ctx, "recipient", password, nil, nil)
require.NoError(t, err)
smtpWaiter.Wait()
senderInfo, err := bridge.GetUserInfo(senderUserID)
require.NoError(t, err)
@ -409,9 +404,6 @@ SGVsbG8gd29ybGQK
require.NoError(t, err)
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)
require.NoError(t, err)
@ -431,8 +423,6 @@ SGVsbG8gd29ybGQK
messageMultipartWithoutTextWithTextAttachment,
}
smtpWaiter.Wait()
for _, m := range messages {
// Dial the server.
client, err := smtp.Dial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetSMTPPort())))
@ -617,9 +607,6 @@ Hello world
require.NoError(t, err)
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)
require.NoError(t, err)
@ -639,8 +626,6 @@ Hello world
messageInlineImageFollowedByText,
}
smtpWaiter.Wait()
for _, m := range messages {
// Dial the server.
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))
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)
require.NoError(t, err)
_, err = bridge.LoginFull(ctx, "recipient", password, nil, nil)
require.NoError(t, err)
smtpWaiter.Wait()
recipientInfo, err := bridge.GetUserInfo(recipientUserID)
require.NoError(t, err)
@ -750,7 +730,7 @@ func TestBridge_SendAddressDisabled(t *testing.T) {
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())
})
})

View File

@ -36,9 +36,6 @@ import (
func TestBridge_Report(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(b *bridge.Bridge, mocks *bridge.Mocks) {
imapWaiter := waitForIMAPServerReady(b)
defer imapWaiter.Done()
syncCh, done := chToType[events.Event, events.SyncFinished](b.GetEvents(events.SyncFinished{}))
defer done()
@ -54,8 +51,6 @@ func TestBridge_Report(t *testing.T) {
require.NoError(t, err)
require.True(t, info.State == bridge.Connected)
imapWaiter.Wait()
// Dial the IMAP port.
conn, err := net.Dial("tcp", fmt.Sprintf("%v:%v", constants.Host, b.GetIMAPPort()))
require.NoError(t, err)

View File

@ -20,6 +20,7 @@ package bridge_test
import (
"context"
"fmt"
"net"
"testing"
"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/constants"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/emersion/go-smtp"
"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) {
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()))
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)
imapClient, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, bridge.GetIMAPPort()))
require.NoError(t, err)
require.NoError(t, imapClient.Logout())
imapWaiter.Wait()
smtpWaiter.Wait()
smtpClient, err := smtp.Dial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetSMTPPort())))
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) {
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)
require.NoError(t, err)
imapWaiter.Wait()
smtpWaiter.Wait()
imapWaiterStopped := waitForIMAPServerStopped(bridge)
defer imapWaiterStopped.Done()
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)
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)
userIDOther, err := bridge.LoginFull(ctx, otherUser, otherPassword, nil, nil)
require.NoError(t, err)
imapWaiter.Wait()
smtpWaiter.Wait()
evtCh, cancel := bridge.GetEvents(events.UserDeauth{})
defer cancel()
@ -115,31 +89,10 @@ func TestServerManager_ServersDoNotStopWhenThereIsStillOneActiveUser(t *testing.
imapClient, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, bridge.GetIMAPPort()))
require.NoError(t, err)
require.NoError(t, imapClient.Logout())
})
})
}
func TestServerManager_ServersStartIfAtLeastOneUserIsLoggedIn(t *testing.T) {
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)
smtpClient, err := smtp.Dial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetSMTPPort())))
require.NoError(t, err)
_, 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())
smtpClient.Close() //nolint:errcheck
})
})
}
@ -162,8 +115,13 @@ func TestServerManager_NetworkLossStopsServers(t *testing.T) {
_, err := bridge.LoginFull(ctx, username, password, nil, nil)
require.NoError(t, err)
imapWaiter.Wait()
smtpWaiter.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
netCtl.Disable()

View File

@ -27,7 +27,6 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/services/userevents"
"github.com/ProtonMail/proton-bridge/v3/internal/updater"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/sirupsen/logrus"
)
func (bridge *Bridge) GetKeychainApp() (string, error) {
@ -134,7 +133,7 @@ func (bridge *Bridge) SetGluonDir(ctx context.Context, newGluonDir string) error
bridge.usersLock.RLock()
defer func() {
logrus.Info("Restarting user event loops")
logPkg.Info("Restarting user event loops")
for _, u := range bridge.users {
u.ResumeEventLoop()
}
@ -149,20 +148,20 @@ func (bridge *Bridge) SetGluonDir(ctx context.Context, newGluonDir string) error
waiters := make([]waiter, 0, len(bridge.users))
logrus.Info("Pausing user event loops for gluon dir change")
logPkg.Info("Pausing user event loops for gluon dir change")
for id, u := range bridge.users {
waiters = append(waiters, waiter{w: u.PauseEventLoopWithWaiter(), id: id})
}
logrus.Info("Waiting on user event loop completion")
logPkg.Info("Waiting on user event loop completion")
for _, waiter := range waiters {
if err := waiter.w.WaitPollFinished(ctx); err != nil {
logrus.WithError(err).Errorf("Failed to wait on event loop pause for user %v", waiter.id)
logPkg.WithError(err).Errorf("Failed to wait on event loop pause for user %v", waiter.id)
return fmt.Errorf("failed on event loop pause: %w", err)
}
}
logrus.Info("Changing gluon directory")
logPkg.Info("Changing gluon directory")
return bridge.serverManager.SetGluonDir(ctx, newGluonDir)
}
@ -330,13 +329,13 @@ func (bridge *Bridge) FactoryReset(ctx context.Context) {
// Wipe the vault.
gluonCacheDir, err := bridge.locator.ProvideGluonCachePath()
if err != nil {
logrus.WithError(err).Error("Failed to provide gluon dir")
logPkg.WithError(err).Error("Failed to provide gluon dir")
} else if err := bridge.vault.Reset(gluonCacheDir); err != nil {
logrus.WithError(err).Error("Failed to reset vault")
logPkg.WithError(err).Error("Failed to reset vault")
}
// Lastly, delete all files except the vault.
if err := bridge.locator.Clear(bridge.vault.Path()); err != nil {
logrus.WithError(err).Error("Failed to clear data paths")
logPkg.WithError(err).Error("Failed to clear data paths")
}
}

View File

@ -28,6 +28,7 @@ import (
"github.com/ProtonMail/gluon/reporter"
"github.com/ProtonMail/go-proton-api"
"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/safe"
"github.com/ProtonMail/proton-bridge/v3/internal/services/imapservice"
@ -38,6 +39,8 @@ import (
"github.com/sirupsen/logrus"
)
var logUser = logrus.WithField("pkg", "bridge/user") //nolint:gochecknoglobals
type UserState int
const (
@ -121,23 +124,28 @@ func (bridge *Bridge) QueryUserInfo(query string) (UserInfo, error) {
}
// 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) {
logrus.WithField("username", logging.Sensitive(username)).Info("Authorizing user for login")
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")
if username == "crash@bandicoot" {
panic("Your wish is my command.. I crash!")
}
client, auth, err := bridge.api.NewClientWithLogin(ctx, username, password)
client, auth, err := bridge.api.NewClientWithLoginWithHVToken(ctx, username, password, hvDetails)
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)
}
if ok := safe.RLockRet(func() bool { return mapHas(bridge.users, auth.UserID) }, bridge.usersLock); ok {
logrus.WithField("userID", auth.UserID).Warn("User already logged in")
logUser.WithField("userID", auth.UserID).Warn("User already logged in")
if err := client.AuthDelete(ctx); err != nil {
logrus.WithError(err).Warn("Failed to delete auth")
logUser.WithError(err).Warn("Failed to delete auth")
}
return nil, proton.Auth{}, ErrUserAlreadyLoggedIn
@ -152,12 +160,13 @@ func (bridge *Bridge) LoginUser(
client *proton.Client,
auth proton.Auth,
keyPass []byte,
hvDetails *proton.APIHVDetails,
) (string, error) {
logrus.WithField("userID", auth.UserID).Info("Logging in authorized user")
logUser.WithField("userID", auth.UserID).Info("Logging in authorized user")
userID, err := try.CatchVal(
func() (string, error) {
return bridge.loginUser(ctx, client, auth.UID, auth.RefreshToken, keyPass)
return bridge.loginUser(ctx, client, auth.UID, auth.RefreshToken, keyPass, hvDetails)
},
)
@ -165,7 +174,7 @@ func (bridge *Bridge) LoginUser(
// Failure to unlock will allow retries, so we do not delete auth.
if !errors.Is(err, ErrFailedToUnlock) {
if deleteErr := client.AuthDelete(ctx); deleteErr != nil {
logrus.WithError(deleteErr).Error("Failed to delete auth")
logUser.WithError(deleteErr).Error("Failed to delete auth")
}
}
return "", fmt.Errorf("failed to login user: %w", err)
@ -188,15 +197,16 @@ func (bridge *Bridge) LoginFull(
getTOTP func() (string, error),
getKeyPass func() ([]byte, error),
) (string, error) {
logrus.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 {
return "", fmt.Errorf("failed to begin login process: %w", err)
}
if auth.TwoFA.Enabled&proton.HasTOTP != 0 {
logrus.WithField("userID", auth.UserID).Info("Requesting TOTP")
logUser.WithField("userID", auth.UserID).Info("Requesting TOTP")
totp, err := getTOTP()
if err != nil {
@ -211,7 +221,7 @@ func (bridge *Bridge) LoginFull(
var keyPass []byte
if auth.PasswordMode == proton.TwoPasswordMode {
logrus.WithField("userID", auth.UserID).Info("Requesting mailbox password")
logUser.WithField("userID", auth.UserID).Info("Requesting mailbox password")
userKeyPass, err := getKeyPass()
if err != nil {
@ -223,10 +233,10 @@ func (bridge *Bridge) LoginFull(
keyPass = password
}
userID, err := bridge.LoginUser(ctx, client, auth, keyPass)
userID, err := bridge.LoginUser(ctx, client, auth, keyPass, nil)
if err != nil {
if deleteErr := client.AuthDelete(ctx); deleteErr != nil {
logrus.WithError(err).Error("Failed to delete auth")
logUser.WithError(err).Error("Failed to delete auth")
}
return "", err
@ -237,7 +247,7 @@ func (bridge *Bridge) LoginFull(
// LogoutUser logs out the given user.
func (bridge *Bridge) LogoutUser(ctx context.Context, userID string) error {
logrus.WithField("userID", userID).Info("Logging out user")
logUser.WithField("userID", userID).Info("Logging out user")
return safe.LockRet(func() error {
user, ok := bridge.users[userID]
@ -257,7 +267,7 @@ func (bridge *Bridge) LogoutUser(ctx context.Context, userID string) error {
// DeleteUser deletes the given user.
func (bridge *Bridge) DeleteUser(ctx context.Context, userID string) error {
logrus.WithField("userID", userID).Info("Deleting user")
logUser.WithField("userID", userID).Info("Deleting user")
syncConfigDir, err := bridge.locator.ProvideIMAPSyncConfigPath()
if err != nil {
@ -278,7 +288,7 @@ func (bridge *Bridge) DeleteUser(ctx context.Context, userID string) error {
}
if err := bridge.vault.DeleteUser(userID); err != nil {
logrus.WithError(err).Error("Failed to delete vault user")
logUser.WithError(err).Error("Failed to delete vault user")
}
bridge.publish(events.UserDeleted{
@ -291,7 +301,7 @@ func (bridge *Bridge) DeleteUser(ctx context.Context, userID string) error {
// SetAddressMode sets the address mode for the given user.
func (bridge *Bridge) SetAddressMode(ctx context.Context, userID string, mode vault.AddressMode) error {
logrus.WithField("userID", userID).WithField("mode", mode).Info("Setting address mode")
logUser.WithField("userID", userID).WithField("mode", mode).Info("Setting address mode")
return safe.RLockRet(func() error {
user, ok := bridge.users[userID]
@ -327,7 +337,7 @@ func (bridge *Bridge) SetAddressMode(ctx context.Context, userID string, mode va
// SendBadEventUserFeedback passes the feedback to the given user.
func (bridge *Bridge) SendBadEventUserFeedback(_ context.Context, userID string, doResync bool) error {
logrus.WithField("userID", userID).WithField("doResync", doResync).Info("Passing bad event feedback to user")
logUser.WithField("userID", userID).WithField("doResync", doResync).Info("Passing bad event feedback to user")
return safe.RLockRet(func() error {
ctx := context.Background()
@ -338,7 +348,7 @@ func (bridge *Bridge) SendBadEventUserFeedback(_ context.Context, userID string,
"Failed to handle event: feedback failed: no such user",
reporter.Context{"user_id": userID},
); rerr != nil {
logrus.WithError(rerr).Error("Failed to report feedback failure")
logUser.WithError(rerr).Error("Failed to report feedback failure")
}
return ErrNoSuchUser
@ -349,7 +359,7 @@ func (bridge *Bridge) SendBadEventUserFeedback(_ context.Context, userID string,
"Failed to handle event: feedback resync",
reporter.Context{"user_id": userID},
); rerr != nil {
logrus.WithError(rerr).Error("Failed to report feedback failure")
logUser.WithError(rerr).Error("Failed to report feedback failure")
}
return user.BadEventFeedbackResync(ctx)
@ -359,7 +369,7 @@ func (bridge *Bridge) SendBadEventUserFeedback(_ context.Context, userID string,
"Failed to handle event: feedback logout",
reporter.Context{"user_id": userID},
); rerr != nil {
logrus.WithError(rerr).Error("Failed to report feedback failure")
logUser.WithError(rerr).Error("Failed to report feedback failure")
}
bridge.logoutUser(ctx, user, true, false, false)
@ -372,8 +382,8 @@ func (bridge *Bridge) SendBadEventUserFeedback(_ context.Context, userID string,
}, bridge.usersLock)
}
func (bridge *Bridge) loginUser(ctx context.Context, client *proton.Client, authUID, authRef string, keyPass []byte) (string, error) {
apiUser, err := client.GetUser(ctx)
func (bridge *Bridge) loginUser(ctx context.Context, client *proton.Client, authUID, authRef string, keyPass []byte, hvDetails *proton.APIHVDetails) (string, error) {
apiUser, err := client.GetUserWithHV(ctx, hvDetails)
if err != nil {
return "", fmt.Errorf("failed to get API user: %w", err)
}
@ -403,11 +413,11 @@ func (bridge *Bridge) loginUser(ctx context.Context, client *proton.Client, auth
// loadUsers tries to load each user in the vault that isn't already loaded.
func (bridge *Bridge) loadUsers(ctx context.Context) error {
logrus.WithField("count", len(bridge.vault.GetUserIDs())).Info("Loading users")
defer logrus.Info("Finished loading users")
logUser.WithField("count", len(bridge.vault.GetUserIDs())).Info("Loading users")
defer logUser.Info("Finished loading users")
return bridge.vault.ForUser(runtime.NumCPU(), func(user *vault.User) error {
log := logrus.WithField("userID", user.UserID())
log := logUser.WithField("userID", user.UserID())
if user.AuthUID() == "" {
log.Info("User is not connected (skipping)")
@ -451,7 +461,7 @@ func (bridge *Bridge) loadUser(ctx context.Context, user *vault.User) error {
if apiErr := new(proton.APIError); errors.As(err, &apiErr) && (apiErr.Code == proton.AuthRefreshTokenInvalid) {
// The session cannot be refreshed, we sign out the user by clearing his auth secrets.
if err := user.Clear(); err != nil {
logrus.WithError(err).Warn("Failed to clear user secrets")
logUser.WithError(err).Warn("Failed to clear user secrets")
}
}
@ -496,24 +506,24 @@ func (bridge *Bridge) addUser(
if err := bridge.addUserWithVault(ctx, client, apiUser, vaultUser, isNew); err != nil {
if _, ok := err.(*resty.ResponseError); ok || isLogin {
logrus.WithError(err).Error("Failed to add user, clearing its secrets from vault")
logUser.WithError(err).Error("Failed to add user, clearing its secrets from vault")
if err := vaultUser.Clear(); err != nil {
logrus.WithError(err).Error("Failed to clear user secrets")
logUser.WithError(err).Error("Failed to clear user secrets")
}
} else {
logrus.WithError(err).Error("Failed to add user")
logUser.WithError(err).Error("Failed to add user")
}
if err := vaultUser.Close(); err != nil {
logrus.WithError(err).Error("Failed to close vault user")
logUser.WithError(err).Error("Failed to close vault user")
}
if isNew {
logrus.Warn("Deleting newly added vault user")
logUser.Warn("Deleting newly added vault user")
if err := bridge.vault.DeleteUser(apiUser.ID); err != nil {
logrus.WithError(err).Error("Failed to delete vault user")
logUser.WithError(err).Error("Failed to delete vault user")
}
}
@ -567,7 +577,7 @@ func (bridge *Bridge) addUserWithVault(
// For example, if the user's addresses change, we need to update them in gluon.
bridge.tasks.Once(func(ctx context.Context) {
async.RangeContext(ctx, user.GetEventCh(), func(event events.Event) {
logrus.WithFields(logrus.Fields{
logUser.WithFields(logrus.Fields{
"userID": apiUser.ID,
"event": event,
}).Debug("Received user event")
@ -618,14 +628,14 @@ func (bridge *Bridge) logoutUser(ctx context.Context, user *user.User, withAPI,
user.SendConfigStatusAbort(ctx, withTelemetry)
}
logrus.WithFields(logrus.Fields{
logUser.WithFields(logrus.Fields{
"userID": user.ID(),
"withAPI": withAPI,
"withData": withData,
}).Debug("Logging out user")
if err := user.Logout(ctx, withAPI); err != nil {
logrus.WithError(err).Error("Failed to logout user")
logUser.WithError(err).Error("Failed to logout user")
}
bridge.heartbeat.SetNbAccount(len(bridge.users))

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) {
smtpWaiter := waitForSMTPServerReady(bridge)
defer smtpWaiter.Done()
userLoginAndSync(ctx, t, bridge, "user", password)
var messageIDs []string
@ -177,8 +174,6 @@ func test_badMessage_badEvent(userFeedback func(t *testing.T, ctx context.Contex
userFeedback(t, ctx, bridge, badUserID)
smtpWaiter.Wait()
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) {
smtpWaiter := waitForSMTPServerReady(bridge)
defer smtpWaiter.Done()
userLoginAndSync(ctx, t, bridge, "user", password)
var messageIDs []string
@ -223,7 +215,6 @@ func TestBridge_User_BadMessage_NoBadEvent(t *testing.T) {
require.NoError(t, c.DeleteMessage(ctx, messageIDs...))
})
smtpWaiter.Wait()
userContinueEventProcess(ctx, t, s, bridge)
})
})
@ -776,20 +767,11 @@ func TestBridge_User_CreateDisabledAddress(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) {
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)))
info, err := bridge.QueryUserInfo(username)
require.NoError(t, err)
imapWaiter.Wait()
smtpWaiter.Wait()
cli, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, bridge.GetIMAPPort()))
require.NoError(t, err)
require.NoError(t, cli.Login(info.Addresses[0], string(info.BridgePass)))

View File

@ -58,7 +58,7 @@ func (bridge *Bridge) handleUserBadEvent(ctx context.Context, user *user.User, e
"error": event.Error,
"error_type": internal.ErrCauseType(event.Error),
}); rerr != nil {
logrus.WithError(rerr).Error("Failed to report failed event handling")
logrus.WithField("pkg", "bridge/event").WithError(rerr).Error("Failed to report failed event handling")
}
user.OnBadEvent(ctx)
@ -70,6 +70,6 @@ func (bridge *Bridge) handleUncategorizedErrorEvent(event events.UncategorizedEv
"error_type": internal.ErrCauseType(event.Error),
"error": event.Error,
}); rerr != nil {
logrus.WithError(rerr).Error("Failed to report failed event handling")
logrus.WithField("pkg", "bridge/event").WithError(rerr).Error("Failed to report failed event handling")
}
}

View File

@ -95,6 +95,6 @@ func TestConfigurationProgress_fed_year_change(t *testing.T) {
require.Equal(t, "bridge.any.configuration", req.MeasurementGroup)
require.Equal(t, "bridge_config_progress", req.Event)
require.Equal(t, 370, req.Values.NbDay)
require.True(t, (req.Values.NbDay == 370) || (req.Values.NbDay == 371)) // leap year is accounted for in the simplest manner.
require.Equal(t, 2, req.Values.NbDaySinceLast)
}

View File

@ -29,27 +29,34 @@ type TLSDialer interface {
DialTLSContext(ctx context.Context, network, address string) (conn net.Conn, err error)
}
func SetBasicTransportTimeouts(t *http.Transport) {
t.MaxIdleConns = 100
t.MaxIdleConnsPerHost = 100
t.IdleConnTimeout = 5 * time.Minute
t.ExpectContinueTimeout = 500 * time.Millisecond
// GODT-126: this was initially 10s but logs from users showed a significant number
// were hitting this timeout, possibly due to flaky wifi taking >10s to reconnect.
// Bumping to 30s for now to avoid this problem.
t.ResponseHeaderTimeout = 30 * time.Second
// If we allow up to 30 seconds for response headers, it is reasonable to allow up
// to 30 seconds for the TLS handshake to take place.
t.TLSHandshakeTimeout = 30 * time.Second
}
// CreateTransportWithDialer creates an http.Transport that uses the given dialer to make TLS connections.
func CreateTransportWithDialer(dialer TLSDialer) *http.Transport {
return &http.Transport{
t := &http.Transport{
DialTLSContext: dialer.DialTLSContext,
Proxy: http.ProxyFromEnvironment,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 5 * time.Minute,
ExpectContinueTimeout: 500 * time.Millisecond,
// GODT-126: this was initially 10s but logs from users showed a significant number
// were hitting this timeout, possibly due to flaky wifi taking >10s to reconnect.
// Bumping to 30s for now to avoid this problem.
ResponseHeaderTimeout: 30 * time.Second,
// If we allow up to 30 seconds for response headers, it is reasonable to allow up
// to 30 seconds for the TLS handshake to take place.
TLSHandshakeTimeout: 30 * time.Second,
Proxy: http.ProxyFromEnvironment,
}
SetBasicTransportTimeouts(t)
return t
}
// BasicTLSDialer implements TLSDialer.

View File

@ -64,7 +64,8 @@ func TestTLSPinInvalid(t *testing.T) {
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)
called, _, reporter, checker, cm := createClientWithPinningDialer(getRootURL())

View File

@ -144,7 +144,8 @@ func TestProxyProvider_FindProxy_CanReachTimeout(t *testing.T) {
r.Error(t, err)
}
func TestProxyProvider_DoHLookup_Quad9(t *testing.T) {
// DISABLED_TestProxyProvider_DoHLookup_Quad9 cannot run on CI, see GODT-3257.
func DISABLED_TestProxyProvider_DoHLookup_Quad9(t *testing.T) {
p := newProxyProvider(NewBasicTLSDialer(""), "", []string{Quad9Provider, GoogleProvider}, async.NoopPanicHandler{})
records, err := p.dohLookup(context.Background(), proxyQuery, Quad9Provider)

View File

@ -29,13 +29,11 @@ using namespace bridgepp;
namespace {
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.
//****************************************************************************************************************************************************
Status GRPCService::SetMainExecutable(ServerContext *, StringValue const *request, Empty *) {
resetHv();
app().log().debug(__FUNCTION__);
app().log().info(QString("SetMainExecutable: %1").arg(QString::fromStdString(request->value())));
return Status::OK;
@ -418,7 +417,19 @@ Status GRPCService::Login(ServerContext *, LoginRequest const *request, Empty *)
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()) {
qtProxy_.sendDelayedEvent(newLoginError(LoginErrorType::USERNAME_PASSWORD_ERROR, usersTab.usernamePasswordErrorMessage()));
return Status::OK;
@ -495,6 +506,7 @@ Status GRPCService::Login2Passwords(ServerContext *, LoginRequest const *request
//****************************************************************************************************************************************************
Status GRPCService::LoginAbort(ServerContext *, LoginAbortRequest const *request, Empty *) {
app().log().debug(__FUNCTION__);
this->resetHv();
loginUsername_ = QString();
return Status::OK;
}
@ -953,3 +965,11 @@ void GRPCService::finishLogin() {
qtProxy_.sendDelayedEvent(newLoginFinishedEvent(user->id(), alreadyExist));
}
//****************************************************************************************************************************************************
//
//****************************************************************************************************************************************************
void GRPCService::resetHv() {
hvWasRequested_ = false;
previousHvUsername_ = "";
}

View File

@ -106,6 +106,7 @@ public: // member functions.
private: // member functions
void finishLogin(); ///< finish the login procedure once the credentials have been validated.
void resetHv(); ///< Resets the human verification state.
private: // data member
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 eventStreamShouldStop_; ///< Should the stream be stopped? Access protected by eventStreamMutex
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.
};

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.
//****************************************************************************************************************************************************

View File

@ -39,6 +39,8 @@ public: // member functions.
UserTable &userTable(); ///< Returns a reference to the user table.
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.
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 nextUserFreeUserError() const; ///< Check if next user login should trigger a Free user error.
bool nextUserTFARequired() const; ///< Check if next user login should requires 2FA.

View File

@ -290,6 +290,20 @@
</item>
</layout>
</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>
<widget class="QCheckBox" name="checkFreeUserError">
<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()
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
Qt6::Widgets
Qt6::Core

View File

@ -15,113 +15,85 @@
// 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/>.
#include "Pch.h"
#include "CommandLine.h"
#include "Settings.h"
#include <bridgepp/CLI/CLIUtils.h>
#include <bridgepp/SessionID/SessionID.h>
using namespace bridgepp;
namespace {
QString const launcherFlag = "--launcher"; ///< launcher flag parameter used for bridge.
QString const noWindowFlag = "--no-window"; ///< The no-window command-line flag.
QString const softwareRendererFlag = "--software-renderer"; ///< The 'software-renderer' command-line flag. enable software rendering for a single execution
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 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();
}
QString const hyphenatedLauncherFlag = "--launcher"; ///< launcher flag parameter used for bridge.
QString const hyphenatedWindowFlag = "--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 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 hyphenatedSetHardwareRendererFlag = "--set-hardware-renderer"; ///< The 'set-hardware-renderer' command-line flag. Hardware rendering will be used for all subsequent executions of the application.
//****************************************************************************************************************************************************
/// \brief Parse the log level from the command-line arguments.
///
/// \param[in] argc The number of arguments passed to the application.
/// \param[in] argv The list of arguments passed to the application.
/// \param[in] args The command-line arguments.
/// \return The log level. if not specified on the command-line, the default log level is returned.
//****************************************************************************************************************************************************
Log::Level parseLogLevel(int argc, char *argv[]) {
QString levelStr = parseGoCLIStringArgument(argc, argv, { "l", "log-level" });
Log::Level parseLogLevel(QStringList const &args) {
QStringList levelStr = parseGoCLIStringArgument(args, {"l", "log-level"});
if (levelStr.isEmpty()) {
return Log::defaultLevel;
}
Log::Level level = Log::defaultLevel;
Log::stringToLevel(levelStr, level);
Log::stringToLevel(levelStr.back(), level);
return level;
}
} // anonymous namespace
//****************************************************************************************************************************************************
/// \param[in] argc number of arguments passed to the application.
/// \param[in] argv list of arguments passed to the application.
/// \param[in] argv list of arguments passed to the application, including the exe name/path at index 0.
/// \return The parsed options.
//****************************************************************************************************************************************************
CommandLineOptions parseCommandLine(int argc, char *argv[]) {
CommandLineOptions parseCommandLine(QStringList const &argv) {
CommandLineOptions options;
bool flagFound = false;
options.launcher = QString::fromLocal8Bit(argv[0]);
bool launcherFlagFound = false;
options.launcher = argv[0];
// 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.
for (int i = 1; i < argc; i++) {
QString const &arg = QString::fromLocal8Bit(argv[i]);
for (int i = 1; i < argv.count(); i++) {
QString const &arg = argv[i];
// 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.
if (arg == softwareRendererFlag) {
if (arg == hyphenatedSoftwareRendererFlag) {
options.bridgeGuiArgs.append(arg);
options.useSoftwareRenderer = true;
}
if (arg == setSoftwareRendererFlag) {
if (arg == hyphenatedSetSoftwareRendererFlag) {
app().settings().setUseSoftwareRenderer(true);
continue; // setting is permanent. no need to keep/pass it to bridge for restart.
}
if (arg == setHardwareRendererFlag) {
if (arg == hyphenatedSetHardwareRendererFlag) {
app().settings().setUseSoftwareRenderer(false);
continue; // setting is permanent. no need to keep/pass it to bridge for restart.
}
if (arg == noWindowFlag) {
if (arg == hyphenatedWindowFlag) {
options.noWindow = true;
}
if (arg == launcherFlag) {
if (arg == hyphenatedLauncherFlag) {
options.bridgeArgs.append(arg);
options.launcher = QString::fromLocal8Bit(argv[++i]);
options.launcher = argv[++i];
options.bridgeArgs.append(options.launcher);
flagFound = true;
launcherFlagFound = true;
}
#ifdef QT_DEBUG
else if (arg == "--attach" || arg == "-a") {
@ -135,22 +107,24 @@ CommandLineOptions parseCommandLine(int argc, char *argv[]) {
options.bridgeGuiArgs.append(arg);
}
}
if (!flagFound) {
if (!launcherFlagFound) {
// add bridge-gui as launcher
options.bridgeArgs.append(launcherFlag);
options.bridgeArgs.append(hyphenatedLauncherFlag);
options.bridgeArgs.append(options.launcher);
}
options.logLevel = parseLogLevel(argc, argv);
QString sessionID = parseGoCLIStringArgument(argc, argv, { "session-id" });
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);
QStringList args;
if (!argv.isEmpty()) {
args = argv.last(argv.count() - 1);
}
options.logLevel = parseLogLevel(args);
QString const sessionID = mostRecentSessionID(args);
options.bridgeArgs.append(hyphenatedSessionIDFlag);
options.bridgeArgs.append(sessionID);
app().setSessionID(sessionID);
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

View File

@ -31,7 +31,7 @@ macro( AppendLib LIB_NAME HINT_PATH)
if( ${PATH_${UP_NAME}} STREQUAL "PATH_${UP_NAME}-NOTFOUND")
message(SEND_ERROR "${LIB_NAME} was not found in ${HINT_PATH}")
else()
list(APPEND DEPLOY_LIBS ${PATH_${UP_NAME}})
list(APPEND DEPLOY_LIBS "${PATH_${UP_NAME}}")
endif()
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.
@ -1334,6 +1346,8 @@ void QMLBackend::connectGrpcEvents() {
connect(client, &GRPCClient::login2PasswordErrorAbort, this, &QMLBackend::login2PasswordErrorAbort);
connect(client, &GRPCClient::loginFinished, this, &QMLBackend::onLoginFinished);
connect(client, &GRPCClient::loginAlreadyLoggedIn, this, &QMLBackend::onLoginAlreadyLoggedIn);
connect(client, &GRPCClient::loginHvRequested, this, &QMLBackend::loginHvRequested);
connect(client, &GRPCClient::loginHvError, this, &QMLBackend::loginHvError);
// update events
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 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 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 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.
@ -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 loginFinished(int index, bool wasSignedOut); ///< Signal for the 'loginFinished' 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 updateManualRestartNeeded(); ///< Signal for the 'updateManualRestartNeeded' 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/SettingsItem.qml</file>
<file>qml/SettingsView.qml</file>
<file>qml/SetupWizard/ClientConfigCertInstall.qml</file>
<file>qml/SetupWizard/ClientListItem.qml</file>
<file>qml/SetupWizard/LeftPane.qml</file>
<file>qml/SetupWizard/ClientConfigAppleMail.qml</file>

View File

@ -182,6 +182,7 @@ TrayIcon::TrayIcon()
, notificationErrorIcon_(loadIconFromSVG(":/qml/icons/ic-alert.svg")) {
this->generateDotIcons();
this->setContextMenu(menu_.get());
this->setToolTip(PROJECT_FULL_NAME);
connect(menu_.get(), &QMenu::aboutToShow, this, &TrayIcon::onMenuAboutToShow);
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
check_exit "CMake failed"
. $cmakeExe --build $buildDir --config "$buildConfig"
check_exit "Build failed"
@ -109,7 +110,7 @@ if ($($args.count) -gt 0 )
{
if ($args[0] = "install")
{
. $cmakeExe --install $buildDir
. $cmakeExe --install "$buildDir" -v
check_exit "Install failed"
}
}

View File

@ -15,7 +15,6 @@
// 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/>.
#include "BridgeApp.h"
#include "BuildConfig.h"
#include "CommandLine.h"
@ -30,13 +29,12 @@
#include <bridgepp/Log/LogUtils.h>
#include <bridgepp/ProcessMonitor.h>
#include "bridgepp/CLI/CLIUtils.h"
#ifdef Q_OS_MACOS
#include "MacOS/SecondInstance.h"
#endif
using namespace bridgepp;
@ -50,17 +48,14 @@ QString const exeSuffix = ".exe";
QString const exeSuffix;
#endif
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 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.
} // anonymous namespace
//****************************************************************************************************************************************************
/// \return The path of the bridge executable.
/// \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();
}
//****************************************************************************************************************************************************
/// // initialize the Qt application.
//****************************************************************************************************************************************************
@ -97,8 +91,6 @@ void initQtApplication() {
#endif // #ifdef Q_OS_MACOS
}
//****************************************************************************************************************************************************
/// \param[in] engine The QML component.
//****************************************************************************************************************************************************
@ -118,13 +110,12 @@ QQmlComponent *createRootQmlComponent(QQmlApplicationEngine &engine) {
rootComponent->loadUrl(QUrl(qrcQmlDir + "/Bridge.qml"));
if (rootComponent->status() != QQmlComponent::Status::Ready) {
QString const &err = rootComponent->errorString();
app().log().error(err);
app().log().error(err);
throw Exception("Could not load QML component", err);
}
return rootComponent;
}
//****************************************************************************************************************************************************
/// \param[in] lock The lock file to be checked.
/// \return True if the lock can be taken, false otherwise.
@ -155,7 +146,6 @@ bool checkSingleInstance(QLockFile &lock) {
return true;
}
//****************************************************************************************************************************************************
/// \return QUrl to reach the bridge API.
//****************************************************************************************************************************************************
@ -184,7 +174,6 @@ QUrl getApiUrl() {
return url;
}
//****************************************************************************************************************************************************
/// \brief Check if bridge is running.
///
@ -199,7 +188,6 @@ bool isBridgeRunning() {
return (!lockFile.tryLock()) && (lockFile.error() == QLockFile::LockFailedError);
}
//****************************************************************************************************************************************************
/// \brief Use api to bring focus on existing bridge instance.
//****************************************************************************************************************************************************
@ -213,8 +201,7 @@ void focusOtherInstance() {
if (!sc.load(path)) {
throw Exception("The gRPC focus service configuration file is invalid.");
}
}
else {
} else {
throw Exception("Server did not provide gRPC Focus service configuration.");
}
@ -225,20 +212,18 @@ void focusOtherInstance() {
if (!client.raise("focusOtherInstance").ok()) {
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());
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()));
}
}
//****************************************************************************************************************************************************
/// \param [in] args list of arguments to pass to bridge.
/// \return bridge executable path
//****************************************************************************************************************************************************
const QString launchBridge(QStringList const &args) {
QString launchBridge(QStringList const &args) {
UPOverseer &overseer = app().bridgeOverseer();
overseer.reset();
@ -251,26 +236,38 @@ const QString launchBridge(QStringList const &args) {
}
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(" ")));
overseer = std::make_unique<Overseer>(new ProcessMonitor(bridgeExePath, params, nullptr), nullptr);
overseer->startWorker(true);
return bridgeExePath;
}
//****************************************************************************************************************************************************
//
//****************************************************************************************************************************************************
void closeBridgeApp() {
app().grpc().quit(); // this will cause the grpc service and the bridge app to close.
UPOverseer &overseer = app().bridgeOverseer();
if (overseer) { // A null overseer means the app was run in 'attach' mode. We're not monitoring it.
UPOverseer const &overseer = app().bridgeOverseer();
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);
}
}
//****************************************************************************************************************************************************
/// \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.
@ -289,12 +286,11 @@ int main(int argc, char *argv[]) {
auto sentryCloser = qScopeGuard([] { sentry_close(); });
try {
QString const& configDir = bridgepp::userConfigDir();
QString const &configDir = bridgepp::userConfigDir();
initQtApplication();
CommandLineOptions const cliOptions = parseCommandLine(argc, argv);
QStringList const argvList = cliArgsToStringList(argc, argv);
CommandLineOptions const cliOptions = parseCommandLine(argvList);
Log &log = initLog();
log.setLevel(cliOptions.logLevel);
@ -309,6 +305,8 @@ int main(int argc, char *argv[]) {
setDockIconVisibleState(!cliOptions.noWindow);
#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.
// 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.
@ -348,7 +346,6 @@ int main(int argc, char *argv[]) {
QQuickWindow::setSceneGraphBackend((app().settings().useSoftwareRenderer() || cliOptions.useSoftwareRenderer) ? "software" : "rhi");
log.info(QString("Qt Quick renderer: %1").arg(QQuickWindow::sceneGraphBackend()));
QQmlApplicationEngine engine;
std::unique_ptr<QQmlComponent> rootComponent(createRootQmlComponent(engine));
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));
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);
});
}
@ -383,7 +380,7 @@ int main(int argc, char *argv[]) {
int result = 0;
if (!startError) {
// 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);
QStringList args = cliOptions.bridgeGuiArgs;
args.append(waitFlag);
@ -412,8 +409,7 @@ int main(int argc, char *argv[]) {
// release the lock file
lock.unlock();
return result;
}
catch (Exception const &e) {
} catch (Exception const &e) {
sentry_uuid_s const uuid = reportSentryException("Exception occurred during main", e);
QString message = e.qwhat();
if (e.showSupportLink()) {

View File

@ -60,7 +60,7 @@ QtObject {
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 {
brief: qsTr("Already signed in")
description: qsTr("This account is already signed in.")
@ -1130,6 +1130,27 @@ QtObject {
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 askDeleteAccount(var user)

View File

@ -17,256 +17,77 @@ import QtQuick.Controls
Item {
id: root
enum Screen {
CertificateInstall,
ProfileInstall
}
property var wizard
signal appleMailAutoconfigCertificateInstallPageShown
signal appleMailAutoconfigProfileInstallPageShow
property bool profilePaneLaunched: false
function showAutoconfig() {
if (Backend.isTLSCertificateInstalled()) {
showProfileInstall();
} else {
showCertificateInstall();
}
}
function showCertificateInstall() {
certificateInstall.reset();
stack.currentIndex = ClientConfigAppleMail.Screen.CertificateInstall;
appleMailAutoconfigCertificateInstallPageShown();
}
function showProfileInstall() {
profileInstall.reset();
stack.currentIndex = ClientConfigAppleMail.Screen.ProfileInstall;
appleMailAutoconfigProfileInstallPageShow();
function reset() {
profilePaneLaunched = false;
}
StackLayout {
id: stack
anchors.fill: parent
ColumnLayout {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: ProtonStyle.wizard_spacing_large
// stack index 0
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
ColumnLayout {
Layout.fillWidth: true
spacing: ProtonStyle.wizard_spacing_medium
ColumnLayout {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: ProtonStyle.wizard_spacing_large
Connections {
function onCertificateInstallCanceled() {
certificateInstall.waitingForCert = false;
certificateInstall.errorString = qsTr("Apple Mail cannot be configured if you do not install the certificate. Please retry.");
certificateInstall.showBugReportLink = false;
}
function onCertificateInstallFailed() {
certificateInstall.waitingForCert = false;
certificateInstall.errorString = qsTr("An error occurred while installing the certificate.");
certificateInstall.showBugReportLink = true;
}
function onCertificateInstallSuccess() {
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
}
}
}
Label {
Layout.alignment: Qt.AlignHCenter
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
}
}
// stack index 1
Item {
id: profileInstall
property bool profilePaneLaunched: false
function reset() {
profilePaneLaunched = false;
}
Layout.fillHeight: true
Image {
Layout.alignment: Qt.AlignHCenter
height: 102
source: "/qml/icons/img-macos-profile-screenshot.png"
width: 364
}
ColumnLayout {
Layout.fillWidth: true
spacing: ProtonStyle.wizard_spacing_medium
ColumnLayout {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: ProtonStyle.wizard_spacing_large
Button {
Layout.fillWidth: true
colorScheme: wizard.colorScheme
text: profilePaneLaunched ? qsTr("I have installed the profile") : qsTr("Install the profile")
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 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
onClicked: {
if (profilePaneLaunched) {
wizard.showClientConfigEnd();
} else {
wizard.user.configureAppleMail(wizard.address);
profilePaneLaunched = true;
}
}
Image {
Layout.alignment: Qt.AlignHCenter
height: 102
source: "/qml/icons/img-macos-profile-screenshot.png"
width: 364
}
ColumnLayout {
Layout.fillWidth: true
spacing: ProtonStyle.wizard_spacing_medium
}
Button {
Layout.fillWidth: true
colorScheme: wizard.colorScheme
secondary: true
text: qsTr("Cancel")
Button {
Layout.fillWidth: true
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();
}
}
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: {
wizard.client = SetupWizard.Client.AppleMail;
if (!Backend.isTLSCertificateInstalled()) {
wizard.showCertInstall();
return
}
wizard.showAppleMailAutoConfig();
}
}
@ -59,6 +63,10 @@ Item {
onClicked: {
wizard.client = SetupWizard.Client.MicrosoftOutlook;
if (root.onMacOS && !Backend.isTLSCertificateInstalled()) {
wizard.showCertInstall();
return
}
wizard.showClientParams();
}
}

View File

@ -34,13 +34,23 @@ Item {
signal startSetup()
function showAppleMailAutoconfigCertificateInstall() {
showAppleMailAutoconfigCommon();
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);
function showCertificateInstall() {
showClientConfigCommon();
if (wizard.client === SetupWizard.Client.AppleMail) {
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();
}
function showAppleMailAutoconfigCommon() {
function showClientConfigCommon() {
titleLabel.text = "";
linkLabel1.clear();
linkLabel2.clear();
@ -49,7 +59,7 @@ Item {
iconWidth = 80;
}
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.");
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);

View File

@ -20,12 +20,14 @@ FocusScope {
enum RootStack {
Login,
TOTP,
MailboxPassword
MailboxPassword,
HV
}
property alias currentIndex: stackLayout.currentIndex
property alias username: usernameTextField.text
property var wizard
property string hvLinkUrl: ""
signal loginAbort(string username, bool wasSignedOut)
@ -47,6 +49,14 @@ FocusScope {
passwordTextField.hidePassword();
secondPasswordTextField.hidePassword();
}
function resetViaHv() {
usernameTextField.enabled = false;
passwordTextField.enabled = false;
signInButton.loading = true;
secondPasswordButton.loading = false;
secondPasswordTextField.enabled = true;
totpLayout.reset();
}
StackLayout {
id: stackLayout
@ -124,6 +134,18 @@ FocusScope {
else
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
}
@ -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,
Login,
ClientConfigSelector,
ClientConfigCertInstall,
ClientConfigAppleMail
}
enum RootStack {
@ -95,8 +96,9 @@ Item {
function showAppleMailAutoConfig() {
backAction = _showClientConfig;
rootStackLayout.currentIndex = SetupWizard.RootStack.TwoPanesView;
clientConfigAppleMail.reset()
rightContent.currentIndex = SetupWizard.ContentStack.ClientConfigAppleMail;
clientConfigAppleMail.showAutoconfig(); // This will trigger signals that will display the appropriate left content.
leftContent.showAppleMailAutoconfigProfileInstall();
}
function showBugReport() {
closeWizard();
@ -118,6 +120,15 @@ Item {
backAction = _showClientConfig;
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 = "") {
backAction = null;
rootStackLayout.currentIndex = SetupWizard.RootStack.TwoPanesView;
@ -146,7 +157,13 @@ Item {
let address = user ? user.addresses[0] : "";
showClientConfig(user, address, true);
}
function onCertificateInstallSuccess() {
if (client === SetupWizard.Client.MicrosoftOutlook) {
showClientParams()
} else {
showAppleMailAutoConfig()
}
}
target: Backend
}
StackLayout {
@ -176,17 +193,6 @@ Item {
width: ProtonStyle.wizard_pane_width
wizard: root
Connections {
function onAppleMailAutoconfigCertificateInstallPageShown() {
leftContent.showAppleMailAutoconfigCertificateInstall();
}
function onAppleMailAutoconfigProfileInstallPageShow() {
leftContent.showAppleMailAutoconfigProfileInstall();
}
target: clientConfigAppleMail
}
Connections {
function onLogin2FARequested() {
leftContent.showLogin2FA();
@ -247,7 +253,14 @@ Item {
id: clientConfigSelector
wizard: root
}
// rightContent stack index 3
ClientConfigCertInstall {
id: clientConfigCertInstall
wizard: root
}
// rightContent stack index 4
ClientConfigAppleMail {
id: clientConfigAppleMail
wizard: root

View File

@ -17,22 +17,22 @@
#include <bridgepp/CLI/CLIUtils.h>
#include <bridgepp/SessionID/SessionID.h>
#include <gtest/gtest.h>
using namespace bridgepp;
//****************************************************************************************************************************************************
//
//****************************************************************************************************************************************************
TEST(CLI, stripStringParameterFromCommandLine) {
struct Test {
struct TestData {
QStringList input;
QStringList expectedOutput;
};
QList<Test> const tests = {
QList<TestData> const tests = {
{{}, {}},
{{ "--a", "-b", "--C" }, { "--a", "-b", "--C" } },
{{ "--string", "value" }, {} },
@ -44,7 +44,45 @@ TEST(CLI, stripStringParameterFromCommandLine) {
{{ "--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);
}
}
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/>.
#include "CLIUtils.h"
#include "../SessionID/SessionID.h"
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

View File

@ -15,18 +15,16 @@
// 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/>.
#ifndef BRIDGEPP_CLI_UTILS_H
#define BRIDGEPP_CLI_UTILS_H
namespace bridgepp {
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

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.
/// \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 newLoginFinishedEvent(QString const &userID, bool wasSignedOut); ///< Create a new LoginFinishedEvent event.
SPStreamEvent newLoginAlreadyLoggedInEvent(QString const &userID); ///< Create a new LoginAlreadyLoggedInEvent event.
SPStreamEvent newLoginHvRequestedEvent(); ///< Create a new LoginHvRequestedEvent
// Update related events
SPStreamEvent newUpdateErrorEvent(grpc::UpdateErrorType errorType); ///< Create a new UpdateErrorEvent event.

View File

@ -23,7 +23,6 @@
#include "../ProcessMonitor.h"
#include "../Log/LogUtils.h"
using namespace google::protobuf;
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] code The The 2FA code.
@ -1221,6 +1234,9 @@ void GRPCClient::processLoginEvent(LoginEvent const &event) {
case TWO_PASSWORDS_ABORT:
emit login2PasswordErrorAbort(QString::fromStdString(error.message()));
break;
case HV_ERROR:
emit loginHvError(QString::fromStdString(error.message()));
break;
default:
this->logError("Unknown login error event received.");
break;
@ -1245,6 +1261,10 @@ void GRPCClient::processLoginEvent(LoginEvent const &event) {
this->logTrace("Login event received: AlreadyLoggedIn.");
emit loginAlreadyLoggedIn(QString::fromStdString(event.finished().userid()));
break;
case LoginEvent::kHvRequested:
this->logTrace("Login event Received: HvRequested");
emit loginHvRequested(QString::fromStdString(event.hvrequested().hvurl()));
break;
default:
this->logError("Unknown Login event received.");
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 login2Passwords(QString const &username, QString const &password); ///< Performs the 'login2Passwords' 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:
void loginUsernamePasswordError(QString const &errMsg);
@ -168,6 +169,8 @@ signals:
void login2PasswordErrorAbort(QString const &errMsg);
void loginFinished(QString const &userID, bool wasSignedOut);
void loginAlreadyLoggedIn(QString const &userID);
void loginHvRequested(QString const &hvUrl);
void loginHvError(QString const &errMsg);
public: // Update related calls
grpc::Status checkUpdate();

View File

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

View File

@ -23,6 +23,10 @@
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
QDateTime sessionIDToDateTime(QString const &sessionID); ///< Parse the date/time from a sessionID.

View File

@ -19,12 +19,14 @@ package cli
import (
"context"
"fmt"
"strings"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal/bridge"
"github.com/ProtonMail/proton-bridge/v3/internal/certs"
"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/abiosoft/ishell"
)
@ -116,6 +118,13 @@ func (f *frontendCLI) showAccountAddressInfo(user bridge.UserInfo, address strin
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) {
f.ShowPrompt(false)
defer f.ShowPrompt(true)
@ -144,7 +153,19 @@ func (f *frontendCLI) loginAccount(c *ishell.Context) {
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 {
f.printAndLogError("Cannot login: ", err)
return
@ -175,7 +196,55 @@ func (f *frontendCLI) loginAccount(c *ishell.Context) {
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 {
f.processAPIError(err)
return

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -100,6 +100,10 @@ func NewLoginAlreadyLoggedInEvent(userID string) *StreamEvent {
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 {
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/certs"
"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/service"
"github.com/ProtonMail/proton-bridge/v3/internal/updater"
@ -95,6 +96,9 @@ type Service struct { // nolint:structcheck
parentPID int
parentPIDDoneCh chan struct{}
showOnStartup bool
hvDetails *proton.APIHVDetails
useHvDetails bool
}
// NewService returns a new instance of the service.
@ -412,6 +416,7 @@ func (s *Service) loginClean() {
s.password[i] = '\x00'
}
s.password = s.password[0:0]
s.useHvDetails = false
}
func (s *Service) finishLogin() {
@ -424,6 +429,11 @@ func (s *Service) finishLogin() {
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 {
s.log.
WithField("hasPass", len(s.password) != 0).
@ -439,8 +449,20 @@ func (s *Service) finishLogin() {
defer done()
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 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.twoPasswordAttemptCount++
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.
func computeFileSocketPath() (string, error) {
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) {
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() {
defer async.HandlePanic(s.panicHandler)
@ -407,7 +415,7 @@ func (s *Service) Login(_ context.Context, login *LoginRequest) (*emptypb.Empty,
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 {
defer s.loginClean()
@ -421,6 +429,13 @@ func (s *Service) Login(_ context.Context, login *LoginRequest) (*emptypb.Empty,
case proton.PaidPlanRequired:
_ = 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:
_ = s.SendEvent(NewLoginError(LoginErrorType_USERNAME_PASSWORD_ERROR, err.Error()))
}
@ -522,7 +537,6 @@ func (s *Service) LoginAbort(_ context.Context, loginAbort *LoginAbortRequest) (
go func() {
defer async.HandlePanic(s.panicHandler)
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

@ -27,5 +27,5 @@ func NewIMAPLogger() *IMAPLogger {
}
func (l *IMAPLogger) Write(p []byte) (n int, err error) {
return logrus.WithField("pkg", "IMAP").WriterLevel(logrus.TraceLevel).Write(p)
return logrus.WithField("pkg", "log/IMAP").WriterLevel(logrus.TraceLevel).Write(p)
}

View File

@ -27,7 +27,7 @@ type SMTPErrorLogger struct {
}
func NewSMTPLogger() *SMTPErrorLogger {
return &SMTPErrorLogger{l: logrus.WithField("pkg", "SMTP")}
return &SMTPErrorLogger{l: logrus.WithField("pkg", "log/SMTP")}
}
func (s *SMTPErrorLogger) Printf(format string, args ...interface{}) {
@ -44,7 +44,7 @@ type SMTPDebugLogger struct {
}
func NewSMTPDebugLogger() *SMTPDebugLogger {
return &SMTPDebugLogger{l: logrus.WithField("pkg", "SMTP")}
return &SMTPDebugLogger{l: logrus.WithField("pkg", "log/SMTP")}
}
func (l *SMTPDebugLogger) Write(p []byte) (n int, err error) {

View File

@ -23,12 +23,15 @@ import (
"errors"
"fmt"
"net/mail"
"strings"
"sync/atomic"
"time"
"github.com/ProtonMail/gluon/async"
"github.com/ProtonMail/gluon/connector"
"github.com/ProtonMail/gluon/imap"
"github.com/ProtonMail/gluon/reporter"
"github.com/ProtonMail/gluon/rfc5322"
"github.com/ProtonMail/gluon/rfc822"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/gopenpgp/v2/crypto"
@ -54,6 +57,7 @@ type Connector struct {
identityState sharedIdentity
client APIClient
telemetry Telemetry
reporter reporter.Reporter
panicHandler async.PanicHandler
sendRecorder *sendrecorder.SendRecorder
@ -75,6 +79,7 @@ func NewConnector(
sendRecorder *sendrecorder.SendRecorder,
panicHandler async.PanicHandler,
telemetry Telemetry,
reporter reporter.Reporter,
showAllMail bool,
syncState *SyncState,
) *Connector {
@ -90,6 +95,7 @@ func NewConnector(
client: apiClient,
telemetry: telemetry,
reporter: reporter,
panicHandler: panicHandler,
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 {
return imap.Message{}, nil, fmt.Errorf("failed to check send hash: %w", err)
} 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.
full, err := s.client.GetFullMessage(ctx, messageID, usertypes.NewProtonAPIScheduler(s.panicHandler), proton.NewDefaultAttachmentAllocator())
@ -671,19 +677,31 @@ func (s *Connector) importMessage(
) (imap.Message, []byte, error) {
var full proton.FullMessage
// addr is primary for combined mode or active for split mode
addr, ok := s.identityState.GetAddress(s.addrID)
if !ok {
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 {
var messageID string
p, err2 := parser.New(bytes.NewReader(literal))
if err2 != nil {
return fmt.Errorf("failed to parse literal: %w", err2)
primaryKey, errKey := addrKR.FirstKey()
if errKey != nil {
return fmt.Errorf("failed to get primary key for import: %w", errKey)
}
if slices.Contains(labelIDs, proton.DraftsLabel) {
msg, err := s.createDraftWithParser(ctx, p, addrKR, addr)
var messageID string
if isDraft {
msg, err := s.createDraftWithParser(ctx, p, primaryKey, addr)
if err != nil {
return fmt.Errorf("failed to create draft: %w", err)
}
@ -699,7 +717,7 @@ func (s *Connector) importMessage(
}
literal = buf.Bytes()
}
str, err := s.client.ImportMessages(ctx, addrKR, 1, 1, []proton.ImportReq{{
str, err := s.client.ImportMessages(ctx, primaryKey, 1, 1, []proton.ImportReq{{
Metadata: proton.ImportMetadata{
AddressID: s.addrID,
LabelIDs: labelIDs,
@ -726,7 +744,7 @@ func (s *Connector) importMessage(
return fmt.Errorf("failed to fetch message: %w", err)
}
if literal, err = message.DecryptAndBuildRFC822(addrKR, full.Message, full.AttData, defaultMessageJobOpts()); err != nil {
if literal, err = message.DecryptAndBuildRFC822(primaryKey, full.Message, full.AttData, defaultMessageJobOpts()); err != nil {
return fmt.Errorf("failed to build message: %w", err)
}
@ -845,3 +863,94 @@ func defaultMailboxPermanentFlags() imap.FlagSet {
func defaultMailboxAttributes() imap.FlagSet {
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.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.panicHandler,
s.telemetry,
s.reporter,
s.showAllMail,
s.syncStateProvider,
)
@ -525,6 +526,7 @@ func (s *Service) buildConnectors() (map[string]*Connector, error) {
s.sendRecorder,
s.panicHandler,
s.telemetry,
s.reporter,
s.showAllMail,
s.syncStateProvider,
)

View File

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

View File

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

View File

@ -39,6 +39,8 @@ import (
"github.com/sirupsen/logrus"
)
var logIMAP = logrus.WithField("pkg", "server/imap") //nolint:gochecknoglobals
type IMAPSettingsProvider interface {
TLSConfig() *tls.Config
LogClient() bool
@ -79,7 +81,7 @@ func newIMAPServer(
gluonCacheDir = ApplyGluonCachePathSuffix(gluonCacheDir)
gluonConfigDir = ApplyGluonConfigPathSuffix(gluonConfigDir)
logrus.WithFields(logrus.Fields{
logIMAP.WithFields(logrus.Fields{
"gluonStore": gluonCacheDir,
"gluonDB": gluonConfigDir,
"version": version,
@ -88,10 +90,9 @@ func newIMAPServer(
}).Info("Creating IMAP server")
if logClient || logServer {
log := logrus.WithField("protocol", "IMAP")
log.Warning("================================================")
log.Warning("THIS LOG WILL CONTAIN **DECRYPTED** MESSAGE DATA")
log.Warning("================================================")
logIMAP.Warning("================================================")
logIMAP.Warning("THIS LOG WILL CONTAIN **DECRYPTED** MESSAGE DATA")
logIMAP.Warning("================================================")
}
var imapClientLog io.Writer
@ -143,7 +144,7 @@ func newIMAPServer(
tasks.Once(func(ctx context.Context) {
async.RangeContext(ctx, imapServer.GetErrorCh(), func(err error) {
logrus.WithError(err).Error("IMAP server error")
logIMAP.WithError(err).Error("IMAP server error")
})
})
@ -176,7 +177,7 @@ func (*storeBuilder) Delete(path, userID string) error {
}
func moveGluonCacheDir(settings IMAPSettingsProvider, oldGluonDir, newGluonDir string) error {
logrus.Infof("gluon cache moving from %s to %s", oldGluonDir, newGluonDir)
logIMAP.WithField("pkg", "service/imap").Infof("gluon cache moving from %s to %s", oldGluonDir, newGluonDir)
oldCacheDir := ApplyGluonCachePathSuffix(oldGluonDir)
if err := files.CopyDir(oldCacheDir, ApplyGluonCachePathSuffix(newGluonDir)); err != nil {
return fmt.Errorf("failed to copy gluon dir: %w", err)
@ -187,7 +188,7 @@ func moveGluonCacheDir(settings IMAPSettingsProvider, oldGluonDir, newGluonDir s
}
if err := os.RemoveAll(oldCacheDir); err != nil {
logrus.WithError(err).Error("failed to remove old gluon cache dir")
logIMAP.WithError(err).Error("failed to remove old gluon cache dir")
}
return nil

View File

@ -55,9 +55,8 @@ type Service struct {
panicHandler async.PanicHandler
reporter reporter.Reporter
loadedUserCount int
log *logrus.Entry
tasks *async.Group
log *logrus.Entry
tasks *async.Group
uidValidityGenerator imap.UIDValidityGenerator
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
}
@ -190,16 +199,16 @@ func (sm *Service) run(ctx context.Context, subscription events.Subscription) {
case evt := <-eventSub.GetChannel():
switch evt.(type) {
case events.ConnStatusDown:
logrus.Info("Server Manager, network down stopping listeners")
sm.log.Info("Server Manager, network down stopping listeners")
if err := sm.closeSMTPServer(ctx); err != nil {
logrus.WithError(err).Error("Failed to close SMTP server")
sm.log.WithError(err).Error("Failed to close SMTP server")
}
if err := sm.stopIMAPListener(ctx); err != nil {
logrus.WithError(err)
sm.log.WithError(err)
}
case events.ConnStatusUp:
logrus.Info("Server Manager, network up starting listeners")
sm.log.Info("Server Manager, network up starting listeners")
sm.handleLoadedUserCountChange(ctx)
}
@ -241,12 +250,12 @@ func (sm *Service) run(ctx context.Context, subscription events.Subscription) {
request.Reply(ctx, nil, err)
case *smRequestAddSMTPAccount:
logrus.WithField("user", r.account.UserID()).Debug("Adding SMTP Account")
sm.log.WithField("user", r.account.UserID()).Debug("Adding SMTP Account")
sm.smtpAccounts.AddAccount(r.account)
request.Reply(ctx, nil, nil)
case *smRequestRemoveSMTPAccount:
logrus.WithField("user", r.account.UserID()).Debug("Removing SMTP Account")
sm.log.WithField("user", r.account.UserID()).Debug("Removing SMTP Account")
sm.smtpAccounts.RemoveAccount(r.account)
request.Reply(ctx, nil, nil)
}
@ -255,30 +264,16 @@ func (sm *Service) run(ctx context.Context, subscription events.Subscription) {
}
func (sm *Service) handleLoadedUserCountChange(ctx context.Context) {
logrus.Infof("Validating Listener State %v", sm.loadedUserCount)
if sm.shouldStartServers() {
if sm.imapListener == nil {
if err := sm.serveIMAP(ctx); err != nil {
logrus.WithError(err).Error("Failed to start IMAP server")
}
sm.log.Infof("Validating Listener State")
if sm.imapListener == nil {
if err := sm.serveIMAP(ctx); err != nil {
sm.log.WithError(err).Error("Failed to start IMAP server")
}
}
if sm.smtpListener == nil {
if err := sm.restartSMTP(ctx); err != nil {
logrus.WithError(err).Error("Failed to start SMTP server")
}
}
} else {
if sm.imapListener != nil {
if err := sm.stopIMAPListener(ctx); err != nil {
logrus.WithError(err).Error("Failed to stop IMAP server")
}
}
if sm.smtpListener != nil {
if err := sm.closeSMTPServer(ctx); err != nil {
logrus.WithError(err).Error("Failed to stop SMTP server")
}
if sm.smtpListener == nil {
if err := sm.restartSMTP(ctx); err != nil {
sm.log.WithError(err).Error("Failed to start SMTP server")
}
}
}
@ -286,12 +281,12 @@ func (sm *Service) handleLoadedUserCountChange(ctx context.Context) {
func (sm *Service) handleClose(ctx context.Context) {
// Close the IMAP server.
if err := sm.closeIMAPServer(ctx); err != nil {
logrus.WithError(err).Error("Failed to close IMAP server")
sm.log.WithError(err).Error("Failed to close IMAP server")
}
// Close the SMTP server.
if err := sm.closeSMTPServer(ctx); err != nil {
logrus.WithError(err).Error("Failed to close SMTP server")
sm.log.WithError(err).Error("Failed to close SMTP server")
}
// Cancel and wait needs to be called here since the SMTP server does not have a way to exit
@ -307,12 +302,7 @@ func (sm *Service) handleAddIMAPUser(ctx context.Context,
) error {
// Due to the many different error exits, performer user count change at this stage rather we split the incrementing
// of users from the logic.
err := sm.handleAddIMAPUserImpl(ctx, connector, addrID, idProvider, syncStateProvider)
if err == nil {
sm.loadedUserCount++
}
return err
return sm.handleAddIMAPUserImpl(ctx, connector, addrID, idProvider, syncStateProvider)
}
func (sm *Service) handleAddIMAPUserImpl(ctx context.Context,
@ -325,7 +315,7 @@ func (sm *Service) handleAddIMAPUserImpl(ctx context.Context,
return fmt.Errorf("no imap server instance running")
}
log := logrus.WithFields(logrus.Fields{
log := sm.log.WithFields(logrus.Fields{
"addrID": addrID,
})
log.Info("Adding user to imap server")
@ -341,7 +331,7 @@ func (sm *Service) handleAddIMAPUserImpl(ctx context.Context,
if isNew {
// If the DB was newly created, clear the sync status; gluon's DB was not found.
logrus.Warn("IMAP user DB was newly created, clearing sync status")
sm.log.Warn("IMAP user DB was newly created, clearing sync status")
// Remove the user from IMAP so we can clear the sync status.
if err := sm.imapServer.RemoveUser(ctx, gluonID, false); err != nil {
@ -415,7 +405,7 @@ func (sm *Service) handleRemoveIMAPUser(ctx context.Context, withData bool, idPr
return fmt.Errorf("no imap server instance running")
}
logrus.WithFields(logrus.Fields{
sm.log.WithFields(logrus.Fields{
"withData": withData,
"addresses": addrIDs,
}).Debug("Removing IMAP user")
@ -423,7 +413,7 @@ func (sm *Service) handleRemoveIMAPUser(ctx context.Context, withData bool, idPr
for _, addrID := range addrIDs {
gluonID, ok := idProvider.GetGluonID(addrID)
if !ok {
logrus.Warnf("Could not find Gluon ID for addrID %v", addrID)
sm.log.Warnf("Could not find Gluon ID for addrID %v", addrID)
continue
}
@ -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)
}
}
sm.loadedUserCount--
}
return nil
@ -480,7 +468,7 @@ func (sm *Service) closeSMTPServer(ctx context.Context) error {
// even after the server has been closed. So we close the listener ourselves to unblock it.
if sm.smtpListener != nil {
logrus.Info("Closing SMTP Listener")
sm.log.Info("Closing SMTP Listener")
if err := sm.smtpListener.Close(); err != nil {
return fmt.Errorf("failed to close SMTP listener: %w", err)
}
@ -489,9 +477,9 @@ func (sm *Service) closeSMTPServer(ctx context.Context) error {
}
if sm.smtpServer != nil {
logrus.Info("Closing SMTP server")
sm.log.Info("Closing SMTP server")
if err := sm.smtpServer.Close(); err != nil {
logrus.WithError(err).Debug("Failed to close SMTP server (expected -- we close the listener ourselves)")
sm.log.WithError(err).Debug("Failed to close SMTP server (expected -- we close the listener ourselves)")
}
sm.smtpServer = nil
@ -504,7 +492,7 @@ func (sm *Service) closeSMTPServer(ctx context.Context) error {
func (sm *Service) closeIMAPServer(ctx context.Context) error {
if sm.imapListener != nil {
logrus.Info("Closing IMAP Listener")
sm.log.Info("Closing IMAP Listener")
if err := sm.imapListener.Close(); err != nil {
return fmt.Errorf("failed to close IMAP listener: %w", err)
@ -516,7 +504,7 @@ func (sm *Service) closeIMAPServer(ctx context.Context) error {
}
if sm.imapServer != nil {
logrus.Info("Closing IMAP server")
sm.log.Info("Closing IMAP server")
if err := sm.imapServer.Close(ctx); err != nil {
return fmt.Errorf("failed to close IMAP server: %w", err)
}
@ -530,7 +518,7 @@ func (sm *Service) closeIMAPServer(ctx context.Context) error {
}
func (sm *Service) restartIMAP(ctx context.Context) error {
logrus.Info("Restarting IMAP server")
sm.log.Info("Restarting IMAP server")
if sm.imapListener != nil {
if err := sm.imapListener.Close(); err != nil {
@ -542,15 +530,11 @@ func (sm *Service) restartIMAP(ctx context.Context) error {
sm.eventPublisher.PublishEvent(ctx, events.IMAPServerStopped{})
}
if sm.shouldStartServers() {
return sm.serveIMAP(ctx)
}
return nil
return sm.serveIMAP(ctx)
}
func (sm *Service) restartSMTP(ctx context.Context) error {
logrus.Info("Restarting SMTP server")
sm.log.Info("Restarting SMTP server")
if err := sm.closeSMTPServer(ctx); err != nil {
return fmt.Errorf("failed to close SMTP: %w", err)
@ -560,16 +544,12 @@ func (sm *Service) restartSMTP(ctx context.Context) error {
sm.smtpServer = newSMTPServer(sm.smtpAccounts, sm.smtpSettings)
if sm.shouldStartServers() {
return sm.serveSMTP(ctx)
}
return nil
return sm.serveSMTP(ctx)
}
func (sm *Service) serveSMTP(ctx context.Context) error {
port, err := func() (int, error) {
logrus.WithFields(logrus.Fields{
sm.log.WithFields(logrus.Fields{
"port": sm.smtpSettings.Port(),
"ssl": sm.smtpSettings.UseSSL(),
}).Info("Starting SMTP server")
@ -583,7 +563,7 @@ func (sm *Service) serveSMTP(ctx context.Context) error {
sm.tasks.Once(func(context.Context) {
if err := sm.smtpServer.Serve(smtpListener); err != nil {
logrus.WithError(err).Info("SMTP server stopped")
sm.log.WithError(err).Info("SMTP server stopped")
}
})
@ -615,7 +595,7 @@ func (sm *Service) serveIMAP(ctx context.Context) error {
return 0, fmt.Errorf("no IMAP server instance running")
}
logrus.WithFields(logrus.Fields{
sm.log.WithFields(logrus.Fields{
"port": sm.imapSettings.Port(),
"ssl": sm.imapSettings.UseSSL(),
}).Info("Starting IMAP server")
@ -654,7 +634,7 @@ func (sm *Service) serveIMAP(ctx context.Context) error {
}
func (sm *Service) stopIMAPListener(ctx context.Context) error {
logrus.Info("Stopping IMAP listener")
sm.log.Info("Stopping IMAP listener")
if sm.imapListener != nil {
if err := sm.imapListener.Close(); err != nil {
return err
@ -679,10 +659,8 @@ func (sm *Service) handleSetGluonDir(ctx context.Context, newGluonDir string) er
return fmt.Errorf("failed to close IMAP: %w", err)
}
sm.loadedUserCount = 0
if err := moveGluonCacheDir(sm.imapSettings, currentGluonDir, newGluonDir); err != nil {
logrus.WithError(err).Error("failed to move GluonCacheDir")
sm.log.WithError(err).Error("failed to move GluonCacheDir")
if err := sm.imapSettings.SetCacheDirectory(currentGluonDir); err != nil {
return fmt.Errorf("failed to revert GluonCacheDir: %w", err)
@ -700,19 +678,13 @@ func (sm *Service) handleSetGluonDir(ctx context.Context, newGluonDir string) er
sm.imapServer = imapServer
if sm.shouldStartServers() {
if err := sm.serveIMAP(ctx); err != nil {
return fmt.Errorf("failed to serve IMAP: %w", err)
}
if err := sm.serveIMAP(ctx); err != nil {
return fmt.Errorf("failed to serve IMAP: %w", err)
}
return nil
}
func (sm *Service) shouldStartServers() bool {
return sm.loadedUserCount >= 1
}
type smRequestClose struct{}
type smRequestRestartIMAP struct{}

View File

@ -29,6 +29,8 @@ import (
"github.com/sirupsen/logrus"
)
var logSMTP = logrus.WithField("pkg", "server/smtp") //nolint:gochecknoglobals
type SMTPSettingsProvider interface {
TLSConfig() *tls.Config
Log() bool
@ -39,7 +41,7 @@ type SMTPSettingsProvider interface {
}
func newSMTPServer(accounts *smtpservice.Accounts, settings SMTPSettingsProvider) *smtp.Server {
logrus.WithField("logSMTP", settings.Log()).Info("Creating SMTP server")
logSMTP.WithField("logSMTP", settings.Log()).Info("Creating SMTP server")
smtpServer := smtp.NewServer(smtpservice.NewBackend(accounts, settings.Identifier()))
@ -57,10 +59,9 @@ func newSMTPServer(accounts *smtpservice.Accounts, settings SMTPSettingsProvider
})
if settings.Log() {
log := logrus.WithField("protocol", "SMTP")
log.Warning("================================================")
log.Warning("THIS LOG WILL CONTAIN **DECRYPTED** MESSAGE DATA")
log.Warning("================================================")
logSMTP.Warning("================================================")
logSMTP.Warning("THIS LOG WILL CONTAIN **DECRYPTED** MESSAGE DATA")
logSMTP.Warning("================================================")
smtpServer.Debug = logging.NewSMTPDebugLogger()
}

View File

@ -27,14 +27,14 @@ var ErrInvalidReturnPath = errors.New("invalid return path")
var ErrNoSuchUser = errors.New("no such user")
var ErrTooManyErrors = errors.New("too many failed requests, please try again later")
type ErrCanNotSendOnAddress struct {
type ErrCannotSendFromAddress struct {
address string
}
func NewErrCanNotSendOnAddress(address string) *ErrCanNotSendOnAddress {
return &ErrCanNotSendOnAddress{address: address}
func NewErrCannotSendFromAddress(address string) *ErrCannotSendFromAddress {
return &ErrCannotSendFromAddress{address: address}
}
func (e ErrCanNotSendOnAddress) Error() string {
return fmt.Sprintf("can't send on address: %v", e.address)
func (e ErrCannotSendFromAddress) Error() string {
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 {
s.log.Errorf("Can't send emails on address: %v", fromAddr.Email)
return &ErrCanNotSendOnAddress{address: fromAddr.Email}
s.log.Errorf("Cannot send emails from address: %v", fromAddr.Email)
return &ErrCannotSendFromAddress{address: fromAddr.Email}
}
// Load the user's mail settings.

View File

@ -146,7 +146,7 @@ func mkdirAllClear(path string) error {
func checksum(path string) (hash string) {
file, err := os.Open(filepath.Clean(path))
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
}
defer file.Close() //nolint:errcheck,gosec

View File

@ -294,7 +294,7 @@ func newImpl(
// Log all requests made by the user.
user.client.AddPostRequestHook(func(_ *resty.Client, r *resty.Response) error {
user.log.Infof("%v: %v %v", r.Status(), r.Request.Method, r.Request.URL)
user.log.WithField("pkg", "gpa/client").Infof("%v: %v %v", r.Status(), r.Request.Method, r.Request.URL)
return nil
})

View File

@ -22,9 +22,11 @@ import (
"errors"
"fmt"
"reflect"
"runtime"
"sync"
"time"
"github.com/ProtonMail/proton-bridge/v3/internal/constants"
"github.com/docker/docker-credential-helpers/credentials"
"github.com/sirupsen/logrus"
)
@ -216,11 +218,7 @@ func isUsable(helper credentials.Helper, err error) bool {
return false
}
creds := &credentials.Credentials{
ServerURL: "bridge/check",
Username: "check",
Secret: "check",
}
creds := getTestCredentials()
if err := retry(func() error {
return helper.Add(creds)
@ -242,6 +240,23 @@ func isUsable(helper credentials.Helper, err error) bool {
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 {
var maxRetry = 5
for r := 0; ; r++ {

View File

@ -19,6 +19,7 @@ package message
import (
"bytes"
"fmt"
"mime"
"net/mail"
"strings"
@ -46,6 +47,12 @@ var (
const InternalIDDomain = `protonmail.internalid`
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 {
case len(decrypted.Msg.Attachments) > 0:
return buildMultipartRFC822(decrypted, opts, buf)
@ -560,3 +567,80 @@ func (bw *boundary) gen() string {
bw.val = algo.HashHexSHA256(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
import (
"bytes"
"net/mail"
"os"
"path/filepath"
@ -1298,3 +1299,96 @@ func TestBuildComplexMIMEType(t *testing.T) {
expectContentTypeParam(`name`, 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.
AddMessageDate bool // Whether to include message time as X-Pm-Date.
AddMessageIDReference bool // Whether to include the MessageID in References.
SanitizeMBOXHeaderLine bool // Whether to ignore header line representing MBOX delimiter
}

View File

@ -205,6 +205,10 @@ func convertForeignEncodings(p *parser.Parser) error {
return p.ConvertMetaCharset()
}).
RegisterContentTypeHandler("text/.*", func(p *parser.Part) error {
if p.IsAttachment() {
return nil
}
return p.ConvertToUTF8()
}).
Walk()
@ -548,6 +552,11 @@ func parseAttachment(h message.Header, body []byte) (Attachment, error) {
att.Header = mimeHeader
mimeType, mimeTypeParams, err := pmmime.ParseMediaType(h.Get("Content-Type"))
if err == pmmime.EmptyContentTypeErr {
mimeType = "text/plain"
err = nil
}
if err != nil {
return Attachment{}, err
}

View File

@ -837,6 +837,17 @@ func TestPatchNewLineWithHtmlBreaks(t *testing.T) {
}
}
func TestParseCp1250Attachment(t *testing.T) {
r := require.New(t)
f := getFileReader("text_plain_xml_attachment_cp1250.eml")
m, err := Parse(f)
r.NoError(err)
r.Len(m.Attachments, 1)
r.Equal("text/xml; charset=windows-1250; name=\"cp1250.xml\"", m.Attachments[0].Header.Get("Content-Type"))
}
func getFileReader(filename string) io.Reader {
f, err := os.Open(filepath.Join("testdata", filename))
if err != nil {

View File

@ -0,0 +1,22 @@
From: Sender <sender@pm.me>
To: Receiver <receiver@pm.me>
Content-Type: multipart/mixed; boundary="------------iOaeSk0jVHjMucH3kQFwS1LB"
This is a multi-part message in MIME format.
--------------iOaeSk0jVHjMucH3kQFwS1LB
Content-Type: text/plain; charset=UTF-8; format=flowed
Content-Transfer-Encoding: 8bit
created by me 😎
--------------iOaeSk0jVHjMucH3kQFwS1LB
Content-Type: text/xml; charset=windows-1250; name="cp1250.xml"
Content-Disposition: attachment; filename="cp1250.xml"
Content-Transfer-Encoding: 8bit
<?xml version="1.0" encoding="WINDOWS-1250" ?>
<pvpoj xmlns="http://schemas.cssz.cz/POJ/PVPOJ2023">
<ulice>Horn<72> n<>m<EFBFBD>st<73></ulice>
<prijmeni><3E>CheckChars<72></prijmeni>
</pvpoj>
--------------iOaeSk0jVHjMucH3kQFwS1LB--

View File

@ -35,6 +35,8 @@ import (
"golang.org/x/text/encoding/htmlindex"
)
var EmptyContentTypeErr = errors.New("empty content type")
func init() {
rfc822.ParseMediaType = ParseMediaType
proton.CharsetReader = CharsetReader
@ -257,7 +259,7 @@ func DecodeCharset(original []byte, contentType string) ([]byte, error) {
// ParseMediaType from MIME doesn't support RFC2231 for non asci / utf8 encodings so we have to pre-parse it.
func ParseMediaType(v string) (string, map[string]string, error) {
if v == "" {
return "", nil, errors.New("empty media type")
return "", nil, EmptyContentTypeErr
}
decoded, err := DecodeHeader(v)
if err != nil {

67
tests/README.md Normal file
View File

@ -0,0 +1,67 @@
# Bridge Integration tests
Tests defined in this folder are using `github.com/cucumber/godog` library to
define scenarios.
The scenarios are defined in `./features/` folder.
The step definition can be found in `./steps_test.go`.
# How to run
All features are run as sub-test of `TestFeatures` in `./bdd_test.go`.
The most simple way to execute is `make test-integration` from project source directory.
There are several environment variables which can be used to control the tests:
* `FEATURES` sets the path to folder / file / line in file to select which
scenarios to run.
FEATURES=${PWD}/tests/features/user/addressmode.feature:162
* `FEATURE_TEST_LOG_LEVEL` the logrus level for tests (affects also testing
bridge instance)
FEATURE_TEST_LOG_LEVEL=trace
* `BRIDGE_API_DEBUG` when enabled
[GPA](https://github.com/ProtonMail/go-proton-api/)
client used in testing bridge instance will log http communication and logrus
is automatically set to `trace`
BRIDGE_API_DEBUG=1
* `GO_PROTON_API_SERVER_LOGGER_ENABLED` GPA mock server will print log line per
each request to stdout (not logrus)
GO_PROTON_API_SERVER_LOGGER_ENABLED=1
* `FEATURE_API_DEBUG` when enabled GPA client for preparation of test
condiditions (see `./ctx_helper_test.go`) will dump http communication to
stdoout.
FEATURE_API_DEBUG=1
* `FEATURE_TEST_LOG_IMAP` when enabled
bridge will dump all (client and server) IMAP communication to logs
and logrus is automatically set to `trace`
FEATURE_TEST_LOG_IMAP=1
* `GLUON_LOG_IMAP_LINE_LIMIT` controls maximal number of lines (by default 1)
which are printed into imap trace log (logrus).
Needs `FEATURE_TEST_LOG_IMAP` enabled to take effect.
GLUON_LOG_IMAP_LINE_LIMIT=1048576
* `FEATURE_TEST_LOG_SMTP` when enabled
bridge will dump all SMTP communication to logs
and logrus is automatically set to `trace`
FEATURE_TEST_LOG_SMTP=1

View File

@ -18,7 +18,6 @@
package tests
import (
"crypto/tls"
"net/http"
"net/url"
"os"
@ -26,6 +25,7 @@ import (
"github.com/Masterminds/semver/v3"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/go-proton-api/server"
"github.com/ProtonMail/proton-bridge/v3/internal/dialer"
)
type API interface {
@ -73,13 +73,14 @@ func newLiveAPI(hostURL string) API {
panic(err)
}
tr := proton.InsecureTransport()
dialer.SetBasicTransportTimeouts(tr)
tr.Proxy = http.ProxyFromEnvironment
return &liveAPI{
Server: server.New(
server.WithProxyOrigin(hostURL),
server.WithProxyTransport(&http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
Proxy: http.ProxyFromEnvironment,
}),
server.WithProxyTransport(tr),
),
domain: url.Hostname(),
}

View File

@ -132,9 +132,19 @@ func getFeatureTags() string {
tags = ""
case "smoke": // Currently this is just a placeholder, as there are no scenarios tagged with @smoke
tags = "@smoke"
case "black": // Currently this is just a placeholder, as there are no scenarios tagged with @smoke
tags = "~@skip-black"
default:
tags = "~@regression && ~@smoke" // To exclude more add `&& ~@tag`
}
return tags
}
func isBlack() bool {
if len(os.Args) == 0 {
return false
}
return os.Args[len(os.Args)-1] == "black"
}

View File

@ -168,7 +168,7 @@ func newTestBugReport(br *bridge.Bridge) *testBugReport {
Title: "title",
Description: "description",
Username: "username",
Email: "email",
Email: "email@pm.me",
EmailClient: "client",
IncludeLogs: false,
}

View File

@ -23,6 +23,7 @@ import (
"crypto/x509"
"encoding/json"
"fmt"
"net/http"
"net/http/cookiejar"
"os"
"path/filepath"
@ -34,6 +35,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/bridge"
"github.com/ProtonMail/proton-bridge/v3/internal/constants"
"github.com/ProtonMail/proton-bridge/v3/internal/cookies"
"github.com/ProtonMail/proton-bridge/v3/internal/dialer"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
frontend "github.com/ProtonMail/proton-bridge/v3/internal/frontend/grpc"
"github.com/ProtonMail/proton-bridge/v3/internal/service"
@ -146,6 +148,16 @@ func (t *testCtx) initBridge() (<-chan events.Event, error) {
logrus.SetLevel(logrus.TraceLevel)
}
rt := t.netCtl.NewRoundTripper(&tls.Config{InsecureSkipVerify: true})
if isBlack() {
// GODT-1602 make sure we don't time out test server
t, ok := rt.(*http.Transport)
if !ok {
panic("expecting http.Transport")
}
dialer.SetBasicTransportTimeouts(t)
}
// Create the bridge.
bridge, eventCh, err := bridge.New(
// App stuff
@ -161,7 +173,7 @@ func (t *testCtx) initBridge() (<-chan events.Event, error) {
persister,
useragent.New(),
t.mocks.TLSReporter,
t.netCtl.NewRoundTripper(&tls.Config{InsecureSkipVerify: true}),
rt,
t.mocks.ProxyCtl,
t.mocks.CrashHandler,
t.reporter,

View File

@ -26,14 +26,20 @@ import (
"github.com/ProtonMail/gluon/async"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/ProtonMail/proton-bridge/v3/internal/dialer"
"github.com/bradenaw/juniper/stream"
)
// withProton executes the given function with a proton manager configured to use the test API.
func (t *testCtx) withProton(fn func(*proton.Manager) error) error {
tr := proton.InsecureTransport()
if isBlack() {
dialer.SetBasicTransportTimeouts(tr)
}
m := proton.New(
proton.WithHostURL(t.api.GetHostURL()),
proton.WithTransport(proton.InsecureTransport()),
proton.WithTransport(tr),
proton.WithAppVersion(t.api.GetAppVersion()),
proton.WithDebug(os.Getenv("FEATURE_API_DEBUG") != ""),
)
@ -88,6 +94,15 @@ func (t *testCtx) runQuarkCmd(ctx context.Context, command string, args ...strin
return out, nil
}
func (t *testCtx) decryptID(id string) ([]byte, error) {
return t.runQuarkCmd(context.Background(),
"encryption:id",
"--decrypt",
"--",
id,
)
}
func (t *testCtx) withAddrKR(
ctx context.Context,
c *proton.Client,

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