Compare commits

...

46 Commits

Author SHA1 Message Date
40f2d8b30f chore: Wakato Bridge 3.7.0 changelog. 2023-11-06 14:51:20 +01:00
95a1acec0d fix(GODT-3097): Warn about PGPInline encryption scheme which will be deprecated. 2023-11-06 14:29:31 +01:00
5ff074cc49 fix(GODT-3106): Broken import route
https://github.com/ProtonMail/go-proton-api/pull/123
2023-11-06 10:36:27 +01:00
4f0660bb8c chore: Wakato Bridge 3.7.0 changelog. 2023-11-03 17:51:46 +01:00
708184439e chore: update changelog for previous versions. 2023-11-03 17:32:23 +01:00
b8a33b9618 fix(GODT-3041): Fix Invalid Or Missing message signature during send
If we update the address after determining the sender address is
different, we also need to refresh the identity state in order to use
the right encryption keys.
2023-11-03 10:35:36 +01:00
1c385d5c9b fix(GODT-3087): Exclude attachment content-disposition part when determining... 2023-11-03 08:55:01 +00:00
96773f3225 fix(GODT-2887): Inline images with Apple Mail
Fix sending of inline images with Apple Mail when not using rich text.
2023-11-02 14:18:28 +00:00
0f320dbd80 fix(GODT-3100): fix issue where a fatal error that bubble up to cli.Run() is not written in the log file. 2023-11-02 13:36:01 +00:00
6cb233473a fix(GODT-3094): Clean up old update files on bridge startup. 2023-11-02 10:43:55 +01:00
1ac4e70115 test(GODT-1224): Add testing around package creation. 2023-11-02 07:24:43 +00:00
07f93d276b fix(GODT-3012): Fix multipart request retries
Multipart request were failing due a bug in resty, which would cause
retries of the same request to end up with no data passed to the server.

https://github.com/ProtonMail/go-proton-api/pull/120
2023-10-30 15:27:39 +01:00
d29571fb01 fix(GODT-3095): Update GOpenPGP 2023-10-30 10:14:52 +01:00
d6000d025e fix(GODT-2935): Do not allow parentID into drafts
When sending a message ensure that if a ParentID matches a proton
message, it is not a draft. This is not supported by the Proton API.
2023-10-25 16:29:39 +02:00
09ef3b20db fix(GODT-2935): Correct error message when draft fails to create 2023-10-25 15:54:46 +02:00
405331d59b fix(GODT-2970): Correctly handle rename of Inbox
https://github.com/ProtonMail/gluon/pull/398
https://github.com/ProtonMail/gluon/pull/399
2023-10-25 15:29:33 +02:00
eff7df2136 chore: Add debug_assemble binary
Attempt to reassemble messages produced by the mailbox state debug tool.
Unfortunately, most of it will only work if the messages have been fully
decrypted. To handle encrypted messages we need to have access to the
user's keyring, which is not available.
2023-10-25 11:43:39 +00:00
5823e3a99f test(GODT-2723): Add importing a message with remote content 2023-10-25 11:39:16 +00:00
26d866bbbd test(GODT-2737): Sending HTML messages to internal 2023-10-25 09:54:17 +00:00
d3f7be059d test(GODT-3036): Keep inline attachment order on GPA Fake Server. 2023-10-24 08:22:22 +00:00
b52706a3ca feat(GODT-3015): Add simple algorithm to deal with multiple attachment for bug report. 2023-10-20 10:14:20 +00:00
aebe7baed0 fix(GODT-2969): Prevent duration corruption for config status event. 2023-10-19 15:43:44 +02:00
ef31e2917c test: make message structure check more verbose. 2023-10-19 14:22:46 +02:00
9eea26459a fix(GODT-3033): Unable to receive new mail
If the IMAP service happened to finish syncing and wanted to reset the
user event service at a time the latter was publishing an event a
deadlock would occur and the user would not receive any new messages.

This change puts the request to revert the event id in a separate
go-routine to avoid this situation from re-occurring. The operational
flow remains unchanged as the event service will only process this
request once the current set of events have been published.
2023-10-18 14:29:27 +02:00
5747b85543 test: Add test around account settings. 2023-10-18 07:45:08 +00:00
ff78a23084 chore: update changelog 2023-10-17 11:58:18 +02:00
2a95e1ab41 test: Support multiple users when waiting for sync event. 2023-10-17 08:17:17 +00:00
ab76cab533 test: Update fake server with defautl draft content-type and test it. 2023-10-17 08:16:39 +00:00
dda2a5d01a chore: fixed type in QA installer CI job name. 2023-10-13 08:50:46 +00:00
c2afb42fd4 fix(GODT-3019): fix title of main window when no account is connected. 2023-10-13 09:12:02 +02:00
1d53044803 feat(GODT-3004): update gopenpgp and dependencies. 2023-10-11 13:12:37 +00:00
d3f8297eb4 fix(GODT-3013): IMAP service getting "stuck"
* Ensure IMAP service sync cancel request waits until the sync has
  completely cancelled rather than just signaling. It's possible that
  due the context reset on `group.Cancel` that something may have not
  have been bookmarked correctly in subsequent sync restarts.

* Handle connection lost/restored events in the services. Removes the
  need to lock bridge users. Which could conflict with other ongoing
  lock operations. Additionally, it ensure that if one service is
  blocked it doesn't block the entire bridge.

* Revise access to bridge user locks.
2023-10-11 11:20:53 +01:00
b02203e3d3 chore: Umshiang Bridge 3.5.2 changelog. 2023-10-10 11:21:31 +02:00
5c7e4e04f9 fix(GODT-2966): Allow permissive parsing of MediaType parameters for import. 2023-10-09 15:14:51 +00:00
d7dadd7578 test: be less aggressive while checking for message structure. 2023-10-09 10:32:51 +00:00
ab9a758d63 fix(GODT-3003): Ensure IMAP State is reset after vault corruption
After we detect that the user has suffered the GODT-3003 bug due the
vault corruption not ensuring that a previous sync state would be
erased, we patch the gluon db directly and then reset the sync state.

After the account is added, the sync is automatically triggered and the
account state fixes itself.
2023-10-09 10:23:58 +01:00
cb0935be96 fix(GODT-3001): Only create system labels during system label sync 2023-10-06 10:09:10 +01:00
441b388f62 fix(GODT-2966): Add more test regarding quoted/unquoted filename in attachment. 2023-10-05 12:27:43 +00:00
cdbcd30d15 fix(GODT-2490): fix sync progress not being reset when toggling split mode. 2023-10-05 11:37:01 +02:00
acc7ca8d4a feat(GODT-2996): set password fields to hidden when resetting the login form. 2023-10-04 15:57:36 +02:00
42e1dd4c41 chore: Vasco da Gama Bridge 3.6.0 changelog. 2023-10-03 16:44:24 +02:00
4cbd3ca832 feat(GODT-2990): change runner tags 2023-10-03 13:49:45 +00:00
de0b6c0737 feat(GODT-2835): Bump GPA adding support for AsyncAttachments for BugReport +... 2023-10-03 13:43:16 +00:00
1c344211d1 fix(GODT-2992): fix link in 'no account view' in main window after 2FA or TOTP are cancelled. 2023-10-03 10:49:24 +02:00
c11a87c16a fix(GODT-2515): customized notification of unavailable keychain on macOS. 2023-10-02 17:02:39 +02:00
3bf4282037 feat(GODT-2940): allow 3 attempts for mailbox password. 2023-10-02 16:50:07 +02:00
94 changed files with 6197 additions and 456 deletions

View File

@ -18,6 +18,10 @@
---
image: gitlab.protontech.ch:4567/go/bridge-internal:test-go1.20
default:
tags:
- shared-small
variables:
GOPRIVATE: gitlab.protontech.ch
GOMAXPROCS: $(( ${CI_TAG_CPU} / 2 ))
@ -118,7 +122,7 @@ stages:
- $(git config --global -l | grep -o 'url.*gitlab.protontech.ch.*insteadof' | xargs -L 1 git config --global --unset &> /dev/null) || echo "nothing to remove"
- git config --global url.https://gitlab-ci-token:${CI_JOB_TOKEN}@${CI_SERVER_HOST}.insteadOf https://${CI_SERVER_HOST}
tags:
- large
- shared-large
# Stage: TEST
@ -129,7 +133,7 @@ lint:
script:
- make lint
tags:
- medium
- shared-medium
bug-report-preview:
stage: test
@ -138,7 +142,7 @@ bug-report-preview:
script:
- make lint-bug-report-preview
tags:
- medium
- shared-medium
.script-test:
stage: test
@ -154,7 +158,7 @@ test-linux:
extends:
- .script-test
tags:
- large
- shared-large
fuzz-linux:
stage: test
@ -163,7 +167,7 @@ fuzz-linux:
script:
- make fuzz
tags:
- large
- shared-large
test-linux-race:
extends:
@ -218,7 +222,7 @@ test-coverage:
- test-integration
- test-integration-nightly
tags:
- small
- shared-small
artifacts:
paths:
- coverage*
@ -282,7 +286,7 @@ build-windows-qa:
variables:
BUILD_TAGS: "build_qa"
trigeer-qa-installer:
trigger-qa-installer:
stage: build
needs: ["lint"]
extends:

View File

@ -133,5 +133,6 @@ Proton Mail Bridge includes the following 3rd party software:
* [yaml](https://gopkg.in/yaml.v3) available under [license](https://github.com/go-yaml/yaml/blob/v3.0.1/LICENSE)
* [docker-credential-helpers](https://github.com/ProtonMail/docker-credential-helpers) available under [license](https://github.com/ProtonMail/docker-credential-helpers/blob/master/LICENSE)
* [go-message](https://github.com/ProtonMail/go-message) available under [license](https://github.com/ProtonMail/go-message/blob/master/LICENSE)
* [resty](https://github.com/LBeernaertProton/resty/v2) available under [license](https://github.com/LBeernaertProton/resty/v2/blob/master/LICENSE)
* [go-keychain](https://github.com/cuthix/go-keychain) available under [license](https://github.com/cuthix/go-keychain/blob/master/LICENSE)
<!-- END AUTOGEN -->

View File

@ -3,6 +3,63 @@
Changelog [format](http://keepachangelog.com/en/1.0.0/)
## Wakato Bridge 3.7.0
### Added
* Test(GODT-1224): Add testing around package creation.
* Add debug_assemble binary.
* Test(GODT-2723): Add importing a message with remote content.
* Test(GODT-2737): Sending HTML messages to internal.
* Test(GODT-3036): Keep inline attachment order on GPA Fake Server.
* GODT-3015: Add simple algorithm to deal with multiple attachment for bug report.
* Test: make message structure check more verbose.
* Test: Add test around account settings.
### Changed
* GODT-3097: Warn about PGPInline encryption scheme which will be deprecated.
* Test: Support multiple users when waiting for sync event.
* Test: Update fake server with defautl draft content-type and test it.
* Test: be less aggressive while checking for message structure.
* GODT-2996: Set password fields to hidden when resetting the login form.
* GODT-2990: Change runner tags.
* GODT-2835: Bump GPA adding support for AsyncAttachments for BugReport +...
* GODT-2940: Allow 3 attempts for mailbox password.
* GODT-3095: Update GOpenPGP.
### Fixed
* GODT-3106: Broken import route.
* GODT-3041: Fix Invalid Or Missing message signature during send.
* GODT-3087: Exclude attachment content-disposition part when determining...
* GODT-2887: Inline images with Apple Mail.
* GODT-3100: Fix issue where a fatal error that bubble up to cli.Run() is not written in the log file.
* GODT-3094: Clean up old update files on bridge startup.
* GODT-3012: Fix multipart request retries.
* GODT-2935: Do not allow parentID into drafts.
* GODT-2935: Correct error message when draft fails to create.
* GODT-2970: Correctly handle rename of Inbox.
* GODT-2969: Prevent duration corruption for config status event.
* Fixed type in QA installer CI job name.
* GODT-3019: Fix title of main window when no account is connected.
* GODT-3013: IMAP service getting "stuck".
* GODT-2966: Allow permissive parsing of MediaType parameters for import.
* GODT-2966: Add more test regarding quoted/unquoted filename in attachment.
* GODT-2490: Fix sync progress not being reset when toggling split mode.
* GODT-2515: Customized notification of unavailable keychain on macOS.
## Vasco da Gama Bridge 3.6.1
### Fixed
* GODT-3033: Unable to receive new mail.
## Umshiang Bridge 3.5.4
### Fixed
* GODT-3033: Unable to receive new mail.
## Vasco da Gama Bridge 3.6.0
### Added
@ -21,6 +78,7 @@ Changelog [format](http://keepachangelog.com/en/1.0.0/)
* GODT-2664: Trigger QA installer.
### Fixed
* GODT-2992: Fix link in 'no account view' in main window after 2FA or TOTP are cancelled.
* GODT-2989: Allow to send bug report when no account connected.
* GODT-2988: Fix setup wizard KB links.
* GODT-2968: Use proper base64 encoded string even for bad password test.
@ -32,6 +90,18 @@ Changelog [format](http://keepachangelog.com/en/1.0.0/)
* GODT-2929: Message dedup with different text transfer encoding.
## Umshiang Bridge 3.5.3
### Changed
* GODT-3004: Update gopenpgp and dependencies.
## Umshiang Bridge 3.5.2
### Fixed
* GODT-3003: Ensure IMAP State is reset after vault corruption.
* GODT-3001: Only create system labels during system label sync.
## Umshiang Bridge 3.5.1

View File

@ -11,7 +11,7 @@ ROOT_DIR:=$(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))
.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.6.0+git
BRIDGE_APP_VERSION?=3.7.0+git
APP_VERSION:=${BRIDGE_APP_VERSION}
APP_FULL_NAME:=Proton Mail Bridge
APP_VENDOR:=Proton AG
@ -304,6 +304,7 @@ ApplyStageInput,BuildStageInput,BuildStageOutput,DownloadStageInput,DownloadStag
StateProvider,Regulator,UpdateApplier,MessageBuilder,APIClient,Reporter,DownloadRateModifier \
> tmp
mv tmp internal/services/syncservice/mocks_test.go
mockgen --package mocks github.com/ProtonMail/gluon/connector IMAPStateWrite > internal/services/imapservice/mocks/mocks.go
lint: gofiles lint-golang lint-license lint-dependencies lint-changelog lint-bug-report

View File

@ -23,7 +23,6 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/app"
"github.com/bradenaw/juniper/xslices"
"github.com/sirupsen/logrus"
)
/*
@ -44,7 +43,5 @@ import (
*/
func main() {
if err := app.New().Run(xslices.Filter(os.Args, func(arg string) bool { return !strings.Contains(arg, "-psn_") })); err != nil {
logrus.Fatal(err)
}
_ = app.New().Run(xslices.Filter(os.Args, func(arg string) bool { return !strings.Contains(arg, "-psn_") }))
}

23
go.mod
View File

@ -5,10 +5,10 @@ go 1.20
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.20230911134257-5eb2eeebbef5
github.com/ProtonMail/gluon v0.17.1-0.20231025125916-5c7941465df8
github.com/ProtonMail/go-autostart v0.0.0-20210130080809-00ed301c8e9a
github.com/ProtonMail/go-proton-api v0.4.1-0.20230925123025-331ad8e6d5ee
github.com/ProtonMail/gopenpgp/v2 v2.7.1-proton
github.com/ProtonMail/go-proton-api v0.4.1-0.20231106093533-5f248dfc820d
github.com/ProtonMail/gopenpgp/v2 v2.7.4-proton
github.com/PuerkitoBio/goquery v1.8.1
github.com/abiosoft/ishell v2.0.0+incompatible
github.com/allan-simon/go-singleinstance v0.0.0-20210120080615-d0997106ab37
@ -43,17 +43,17 @@ require (
github.com/vmihailenco/msgpack/v5 v5.3.5
go.uber.org/goleak v1.2.1
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1
golang.org/x/net v0.10.0
golang.org/x/sys v0.8.0
golang.org/x/text v0.9.0
google.golang.org/grpc v1.53.0
golang.org/x/net v0.17.0
golang.org/x/sys v0.13.0
golang.org/x/text v0.13.0
google.golang.org/grpc v1.56.3
google.golang.org/protobuf v1.30.0
howett.net/plist v1.0.0
)
require (
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf // indirect
github.com/ProtonMail/go-crypto v0.0.0-20230518184743-7afd39499903 // indirect
github.com/ProtonMail/go-crypto v0.0.0-20230717121622-edf196117233 // indirect
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect
github.com/ProtonMail/go-srp v0.0.7 // indirect
github.com/abiosoft/readline v0.0.0-20180607040430-155bce2042db // indirect
@ -79,7 +79,7 @@ require (
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/gofrs/uuid v4.3.0+incompatible // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
@ -110,16 +110,17 @@ require (
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
gitlab.com/c0b/go-ordered-json v0.0.0-20201030195603-febf46534d5a // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.9.0 // indirect
golang.org/x/crypto v0.14.0 // indirect
golang.org/x/mod v0.8.0 // indirect
golang.org/x/sync v0.2.0 // indirect
golang.org/x/tools v0.6.0 // indirect
google.golang.org/genproto v0.0.0-20230221151758-ace64dc21148 // indirect
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace (
github.com/docker/docker-credential-helpers => github.com/ProtonMail/docker-credential-helpers v1.1.0
github.com/emersion/go-message => github.com/ProtonMail/go-message v0.13.1-0.20230526094639-b62c999c85b7
github.com/go-resty/resty/v2 => github.com/LBeernaertProton/resty/v2 v2.0.0-20231030122409-92db8bee3605
github.com/keybase/go-keychain => github.com/cuthix/go-keychain v0.0.0-20230517073537-fc1740a83768
)

58
go.sum
View File

@ -15,6 +15,8 @@ github.com/0xAX/notificator v0.0.0-20220220101646-ee9b8921e557 h1:l6surSnJ3RP4qA
github.com/0xAX/notificator v0.0.0-20220220101646-ee9b8921e557/go.mod h1:sTrmvD/TxuypdOERsDOS7SndZg0rzzcCi1b6wQMXUYM=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/LBeernaertProton/resty/v2 v2.0.0-20231030122409-92db8bee3605 h1:54Fh3JS6s2Tjy6ZIRLtt1amZOqfYDcjErdye45z8fkQ=
github.com/LBeernaertProton/resty/v2 v2.0.0-20231030122409-92db8bee3605/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A=
github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g=
github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
@ -23,24 +25,23 @@ github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf h1:yc9daCCYUefEs
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf/go.mod h1:o0ESU9p83twszAU8LBeJKFAAMX14tISa0yk4Oo5TOqo=
github.com/ProtonMail/docker-credential-helpers v1.1.0 h1:+kvUIpwWcbtP3WFv5sSvkFn/XLzSqPOB5AAthuk9xPk=
github.com/ProtonMail/docker-credential-helpers v1.1.0/go.mod h1:mK0aBveCxhnQ756AmaTfXMZDeULvheYVhF/MWMErN5g=
github.com/ProtonMail/gluon v0.17.1-0.20230911134257-5eb2eeebbef5 h1:O4BusNL870VgVVDSUX2Oaz8A/fNtJhakUKwx0YBIdn8=
github.com/ProtonMail/gluon v0.17.1-0.20230911134257-5eb2eeebbef5/go.mod h1:Og5/Dz1MiGpCJn51XujZwxiLG7WzvvjE5PRpZBQmAHo=
github.com/ProtonMail/gluon v0.17.1-0.20231025125916-5c7941465df8 h1:sG0o5pEoS2z2jNR9zK7Juq5Tr3X+GfHmQ8L99RPowaE=
github.com/ProtonMail/gluon v0.17.1-0.20231025125916-5c7941465df8/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=
github.com/ProtonMail/go-crypto v0.0.0-20230322105811-d73448b7e800/go.mod h1:8TI4H3IbrackdNgv+92dI+rhpCaLqM0IfpgCgenFvRE=
github.com/ProtonMail/go-crypto v0.0.0-20230518184743-7afd39499903 h1:ZK3C5DtzV2nVAQTx5S5jQvMeDqWtD1By5mOoyY/xJek=
github.com/ProtonMail/go-crypto v0.0.0-20230518184743-7afd39499903/go.mod h1:8TI4H3IbrackdNgv+92dI+rhpCaLqM0IfpgCgenFvRE=
github.com/ProtonMail/go-crypto v0.0.0-20230717121622-edf196117233 h1:bdoKdh0f66/lrgVfYlxw0aqISY/KOqXmFJyGt7rGmnc=
github.com/ProtonMail/go-crypto v0.0.0-20230717121622-edf196117233/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
github.com/ProtonMail/go-message v0.13.1-0.20230526094639-b62c999c85b7 h1:+j+Kd/DyZ/qGfMT9htAT7HxqIEbZHsatsx+m8AoV6fc=
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.20230925123025-331ad8e6d5ee h1:CzFXOiflEZZqT3HQqj2I5AkIprRbc/c6/lToPdEKzxM=
github.com/ProtonMail/go-proton-api v0.4.1-0.20230925123025-331ad8e6d5ee/go.mod h1:Y3ea3i1UbqHz5vq43odmAAd6lmR4nx0ZIQ32tqMfxTY=
github.com/ProtonMail/go-proton-api v0.4.1-0.20231106093533-5f248dfc820d h1:LI2kvxBisX19f7lyMh0H6NcAHHg/Y7/x/xZWtxVrXOc=
github.com/ProtonMail/go-proton-api v0.4.1-0.20231106093533-5f248dfc820d/go.mod h1:WEXJqj5DSc2YI77SgXdpMY0nk33Qy92Vu2r4tOEazA8=
github.com/ProtonMail/go-srp v0.0.7 h1:Sos3Qk+th4tQR64vsxGIxYpN3rdnG9Wf9K4ZloC1JrI=
github.com/ProtonMail/go-srp v0.0.7/go.mod h1:giCp+7qRnMIcCvI6V6U3S1lDDXDQYx2ewJ6F/9wdlJk=
github.com/ProtonMail/gopenpgp/v2 v2.7.1-proton h1:YS6M20yvjCJPR1r4ADW5TPn6rahs4iAyZaACei86bEc=
github.com/ProtonMail/gopenpgp/v2 v2.7.1-proton/go.mod h1:S1lYsaGHykYpxxh2SnJL6ypcAlANKj5NRSY6HxKryKQ=
github.com/ProtonMail/gopenpgp/v2 v2.7.4-proton h1:8tqHYM6IGsdEc6Vxf1TWiwpHNj8yIEQNACPhxsDagrk=
github.com/ProtonMail/gopenpgp/v2 v2.7.4-proton/go.mod h1:omVkSsfPAhmptzPF/piMXb16wKIWUvVhZbVW7sJKh0A=
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
github.com/abiosoft/ishell v2.0.0+incompatible h1:zpwIuEHc37EzrsIYah3cpevrIc8Oma7oZPxr03tlmmw=
@ -64,6 +65,7 @@ github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJm
github.com/bradenaw/juniper v0.12.0 h1:Q/7icpPQD1nH/La5DobQfNEtwyrBSiSu47jOQx7lJEM=
github.com/bradenaw/juniper v0.12.0/go.mod h1:Z2B7aJlQ7xbfWsnMLROj5t/5FQ94/MkIdKC30J4WvzI=
github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
@ -155,8 +157,6 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-resty/resty/v2 v2.7.0 h1:me+K9p3uhSmXtrBZ4k9jcEAfJmuC8IivWHwaLZwPrFY=
github.com/go-resty/resty/v2 v2.7.0/go.mod h1:9PWDzw47qPphMRFfhsyk0NnSgvluHcljSMVIq3w7q0I=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
@ -178,8 +178,8 @@ github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@ -419,9 +419,10 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@ -464,14 +465,15 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@ -514,16 +516,22 @@ golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
@ -531,12 +539,16 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5-0.20201125200606-c27b9fd57aec/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -582,13 +594,13 @@ google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20230221151758-ace64dc21148 h1:muK+gVBJBfFb4SejshDBlN2/UgxCCOKH9Y34ljqEGOc=
google.golang.org/genproto v0.0.0-20230221151758-ace64dc21148/go.mod h1:3Dl5ZL0q0isWJt+FVcfpQyirqemEuLAK/iFvg1UP1Hw=
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 h1:KpwkzHKEF7B9Zxg18WzOa7djJ+Ha5DzthMyZYQfEn2A=
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.53.0 h1:LAv2ds7cmFV/XTS3XG1NneeENYrXGmorPxsBbptIjNc=
google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw=
google.golang.org/grpc v1.56.3 h1:8I4C0Yq1EjstUzUJzpcRVbuYA2mODtEmpWiQoN/b2nc=
google.golang.org/grpc v1.56.3/go.mod h1:I9bI3vqKfayGqPUAwGdOSu7kt6oIJLixfffKrpXqQ9s=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=

View File

@ -204,7 +204,7 @@ func run(c *cli.Context) error {
}()
// Restart the app if requested.
return withRestarter(exe, func(restarter *restarter.Restarter) error {
err = withRestarter(exe, func(restarter *restarter.Restarter) error {
// Handle crashes with various actions.
return withCrashHandler(restarter, reporter, func(crashHandler *crash.Handler, quitCh <-chan struct{}) error {
migrationErr := migrateOldVersions()
@ -276,6 +276,9 @@ func run(c *cli.Context) error {
b.PushError(bridge.ErrVaultCorrupt)
}
// Remove old updates files
b.RemoveOldUpdates()
// Start telemetry heartbeat process
b.StartHeartbeat(b)
@ -290,6 +293,13 @@ func run(c *cli.Context) error {
})
})
})
// if an error occurs, it must be logged now because we're about to close the log file.
if err != nil {
logrus.Fatal(err)
}
return err
}
// If there's another instance already running, try to raise it and exit.

View File

@ -155,7 +155,7 @@ func newUpdater(locations *locations.Locations) (*updater.Updater, error) {
}
return updater.NewUpdater(
updater.NewInstaller(versioner.New(updatesDir)),
versioner.New(updatesDir),
verifier,
constants.UpdateName,
runtime.GOOS,

View File

@ -487,27 +487,15 @@ func (bridge *Bridge) remWatcher(watcher *watcher.Watcher[events.Event]) {
watcher.Close()
}
func (bridge *Bridge) onStatusUp(ctx context.Context) {
func (bridge *Bridge) onStatusUp(_ context.Context) {
logrus.Info("Handling API status up")
safe.RLock(func() {
for _, user := range bridge.users {
user.OnStatusUp(ctx)
}
}, bridge.usersLock)
bridge.goLoad()
}
func (bridge *Bridge) onStatusDown(ctx context.Context) {
logrus.Info("Handling API status down")
safe.RLock(func() {
for _, user := range bridge.users {
user.OnStatusDown(ctx)
}
}, bridge.usersLock)
for backoff := time.Second; ; backoff = min(backoff*2, 30*time.Second) {
select {
case <-ctx.Done():

View File

@ -585,7 +585,7 @@ func TestBridge_MissingGluonStore(t *testing.T) {
require.NoError(t, os.RemoveAll(gluonDir))
// Bridge starts but can't find the gluon store dir; there should be no error.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
withBridgeWaitForServers(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
// ...
})
})

View File

@ -19,6 +19,7 @@ package bridge
import (
"context"
"errors"
"io"
"github.com/ProtonMail/go-proton-api"
@ -33,63 +34,133 @@ const (
DefaultMaxSessionCountForBugReport = 10
)
func (bridge *Bridge) ReportBug(ctx context.Context, osType, osVersion, title, description, username, email, client string, attachLogs bool) error {
var account = username
type ReportBugReq struct {
OSType string
OSVersion string
Title string
Description string
Username string
Email string
EmailClient string
IncludeLogs bool
}
if info, err := bridge.QueryUserInfo(username); err == nil {
account = info.Username
func (bridge *Bridge) ReportBug(ctx context.Context, report *ReportBugReq) error {
if info, err := bridge.QueryUserInfo(report.Username); err == nil {
report.Username = info.Username
} else if userIDs := bridge.GetUserIDs(); len(userIDs) > 0 {
if err := bridge.vault.GetUser(userIDs[0], func(user *vault.User) {
account = user.Username()
report.Username = user.Username()
}); err != nil {
return err
}
}
var attachment []proton.ReportBugAttachment
if attachLogs {
logsPath, err := bridge.locator.ProvideLogsPath()
var attachments []proton.ReportBugAttachment
if report.IncludeLogs {
logs, err := bridge.CollectLogs()
if err != nil {
return err
}
buffer, err := logging.ZipLogsForBugReport(logsPath, DefaultMaxSessionCountForBugReport, DefaultMaxBugReportZipSize)
if err != nil {
return err
}
body, err := io.ReadAll(buffer)
if err != nil {
return err
}
attachment = append(attachment, proton.ReportBugAttachment{
Name: "logs.zip",
Filename: "logs.zip",
MIMEType: "application/zip",
Body: body,
})
attachments = append(attachments, logs)
}
safe.Lock(func() {
var firstAtt proton.ReportBugAttachment
if len(attachments) > 0 && report.IncludeLogs {
firstAtt = attachments[0]
}
attachmentType := proton.AttachmentTypeSync
if len(attachments) > 1 {
attachmentType = proton.AttachmentTypeAsync
}
token, err := bridge.createTicket(ctx, report, attachmentType, firstAtt)
if err != nil || token == "" {
return err
}
safe.RLock(func() {
for _, user := range bridge.users {
user.ReportBugSent()
}
}, bridge.usersLock)
return bridge.api.ReportBug(ctx, proton.ReportBugReq{
OS: osType,
OSVersion: osVersion,
// if we have a token we can append more attachment to the bugReport
for i, att := range attachments {
if i == 0 && report.IncludeLogs {
continue
}
err := bridge.appendComment(ctx, token, att)
if err != nil {
return err
}
}
return err
}
Title: "[Bridge] Bug - " + title,
Description: description,
func (bridge *Bridge) CollectLogs() (proton.ReportBugAttachment, error) {
logsPath, err := bridge.locator.ProvideLogsPath()
if err != nil {
return proton.ReportBugAttachment{}, err
}
Client: client,
buffer, err := logging.ZipLogsForBugReport(logsPath, DefaultMaxSessionCountForBugReport, DefaultMaxBugReportZipSize)
if err != nil {
return proton.ReportBugAttachment{}, err
}
body, err := io.ReadAll(buffer)
if err != nil {
return proton.ReportBugAttachment{}, err
}
return proton.ReportBugAttachment{
Name: "logs.zip",
Filename: "logs.zip",
MIMEType: "application/zip",
Body: body,
}, nil
}
func (bridge *Bridge) createTicket(ctx context.Context, report *ReportBugReq,
asyncAttach proton.AttachmentType, att proton.ReportBugAttachment) (string, error) {
var attachments []proton.ReportBugAttachment
attachments = append(attachments, att)
res, err := bridge.api.ReportBug(ctx, proton.ReportBugReq{
OS: report.OSType,
OSVersion: report.OSVersion,
Title: "[Bridge] Bug - " + report.Title,
Description: report.Description,
Client: report.EmailClient,
ClientType: proton.ClientTypeEmail,
ClientVersion: constants.AppVersion(bridge.curVersion.Original()),
Username: account,
Email: email,
}, attachment...)
Username: report.Username,
Email: report.Email,
AsyncAttachments: asyncAttach,
}, attachments...)
if err != nil || asyncAttach != proton.AttachmentTypeAsync {
return "", err
}
if asyncAttach == proton.AttachmentTypeAsync && res.Token == nil {
return "", errors.New("no token returns for AsyncAttachments")
}
return *res.Token, nil
}
func (bridge *Bridge) appendComment(ctx context.Context, token string, att proton.ReportBugAttachment) error {
var attachments []proton.ReportBugAttachment
attachments = append(attachments, att)
return bridge.api.ReportBugAttachement(ctx, proton.ReportBugAttachmentReq{
Product: proton.ClientTypeEmail,
Body: "Comment adding attachment: " + att.Filename,
Token: token,
}, attachments...)
}

View File

@ -22,7 +22,7 @@ import (
)
func (bridge *Bridge) ReportBugClicked() {
safe.Lock(func() {
safe.RLock(func() {
for _, user := range bridge.users {
user.ReportBugClicked()
}
@ -30,7 +30,7 @@ func (bridge *Bridge) ReportBugClicked() {
}
func (bridge *Bridge) AutoconfigUsed(client string) {
safe.Lock(func() {
safe.RLock(func() {
for _, user := range bridge.users {
user.AutoconfigUsed(client)
}
@ -38,7 +38,7 @@ func (bridge *Bridge) AutoconfigUsed(client string) {
}
func (bridge *Bridge) KBArticleOpened(article string) {
safe.Lock(func() {
safe.RLock(func() {
for _, user := range bridge.users {
user.KBArticleOpened(article)
}

View File

@ -154,3 +154,7 @@ func (testUpdater *TestUpdater) GetVersionInfo(_ context.Context, _ updater.Down
func (testUpdater *TestUpdater) InstallUpdate(_ context.Context, _ updater.Downloader, _ updater.VersionInfo) error {
return nil
}
func (testUpdater *TestUpdater) RemoveOldUpdates() error {
return nil
}

View File

@ -336,6 +336,9 @@ func TestBridge_SendInvite(t *testing.T) {
}
func TestBridge_SendAddTextBodyPartIfNotExists(t *testing.T) {
// NOTE: Prior to GODT-2887, these tests had inline images, however after the implementation to support
// inline images new parts are injected to reference inline images without content-id set. The images
// in this test have been changed to regular attachments to keep the original checks in place.
const messageMultipartWithoutText = `Content-Type: multipart/mixed;
boundary="Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84"
Subject: A new message
@ -343,7 +346,7 @@ Date: Mon, 13 Mar 2023 16:06:16 +0100
--Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84
Content-Disposition: inline;
Content-Disposition: attachment;
filename=Cat_August_2010-4.jpeg
Content-Type: image/jpeg;
name="Cat_August_2010-4.jpeg"
@ -360,7 +363,7 @@ Subject: A new message Part2
Date: Mon, 13 Mar 2023 16:06:16 +0100
--Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84
Content-Disposition: inline;
Content-Disposition: attachment;
filename=Cat_August_2010-4.jpeg
Content-Type: image/jpeg;
name="Cat_August_2010-4.jpeg"
@ -520,3 +523,181 @@ SGVsbG8gd29ybGQK
})
})
}
func TestBridge_SendInlineImage(t *testing.T) {
const messageInlineImageOnly = `Content-Type: multipart/mixed;
boundary="Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84"
Subject: A new message
Date: Mon, 13 Mar 2023 16:06:16 +0100
--Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84
Content-Disposition: inline;
filename=Cat_August_2010-4.jpeg
Content-Type: image/jpeg;
name="Cat_August_2010-4.jpeg"
Content-Transfer-Encoding: base64
SGVsbG8gd29ybGQ=
--Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84--
`
const messageInlineImageWithHTML = `Content-Type: multipart/mixed;
boundary="Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84"
Subject: A new message Part2
Date: Mon, 13 Mar 2023 16:06:16 +0100
--Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84
Content-Type: text/html;charset=utf8
Content-Transfer-Encoding: quoted-printable
Hello world
--Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84
Content-Disposition: inline;
filename=Cat_August_2010-4.jpeg
Content-Type: image/jpeg;
name="Cat_August_2010-4.jpeg"
Content-Transfer-Encoding: base64
SGVsbG8gd29ybGQ=
--Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84--
`
const messageInlineImageWithText = `Content-Type: multipart/mixed;
boundary="Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84"
Subject: A new message Part3
Date: Mon, 13 Mar 2023 16:06:16 +0100
--Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84
Content-Type: text/plain;charset=utf8
Content-Transfer-Encoding: quoted-printable
Hello world
--Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84
Content-Disposition: inline;
filename=Cat_August_2010-4.jpeg
Content-Type: image/jpeg;
name="Cat_August_2010-4.jpeg"
Content-Transfer-Encoding: base64
SGVsbG8gd29ybGQ=
--Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84--
`
const messageInlineImageFollowedByText = `Content-Type: multipart/mixed;
boundary="Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84"
Subject: A new message Part4
Date: Mon, 13 Mar 2023 16:06:16 +0100
--Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84
Content-Disposition: inline;
filename=Cat_August_2010-4.jpeg
Content-Type: image/jpeg;
name="Cat_August_2010-4.jpeg"
Content-Transfer-Encoding: base64
SGVsbG8gd29ybGQ=
--Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84
Content-Type: text/plain;charset=utf8
Content-Transfer-Encoding: quoted-printable
Hello world
--Apple-Mail=_E7AC06C7-4EB2-4453-8CBB-80F4412A7C84--
`
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
_, _, err := s.CreateUser("recipient", password)
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)
senderInfo, err := bridge.GetUserInfo(senderUserID)
require.NoError(t, err)
recipientInfo, err := bridge.GetUserInfo(recipientUserID)
require.NoError(t, err)
messages := []string{
messageInlineImageOnly,
messageInlineImageWithHTML,
messageInlineImageWithText,
messageInlineImageFollowedByText,
}
smtpWaiter.Wait()
for _, m := range messages {
// Dial the server.
client, err := smtp.Dial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetSMTPPort())))
require.NoError(t, err)
defer client.Close() //nolint:errcheck
// Upgrade to TLS.
require.NoError(t, client.StartTLS(&tls.Config{InsecureSkipVerify: true}))
// Authorize with SASL LOGIN.
require.NoError(t, client.Auth(sasl.NewLoginClient(
senderInfo.Addresses[0],
string(senderInfo.BridgePass)),
))
// Send the message.
require.NoError(t, client.SendMail(
senderInfo.Addresses[0],
[]string{recipientInfo.Addresses[0]},
strings.NewReader(m),
))
}
// Connect the sender IMAP client.
senderIMAPClient, err := eventuallyDial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetIMAPPort())))
require.NoError(t, err)
require.NoError(t, senderIMAPClient.Login(senderInfo.Addresses[0], string(senderInfo.BridgePass)))
defer senderIMAPClient.Logout() //nolint:errcheck
// Connect the recipient IMAP client.
recipientIMAPClient, err := eventuallyDial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetIMAPPort())))
require.NoError(t, err)
require.NoError(t, recipientIMAPClient.Login(recipientInfo.Addresses[0], string(recipientInfo.BridgePass)))
defer recipientIMAPClient.Logout() //nolint:errcheck
require.Eventually(t, func() bool {
messages, err := clientFetch(senderIMAPClient, `Sent`, imap.FetchBodyStructure)
require.NoError(t, err)
if len(messages) != 4 {
return false
}
// messages may not be in order
for _, message := range messages {
require.Equal(t, 1, len(message.BodyStructure.Parts))
require.Equal(t, "multipart", message.BodyStructure.MIMEType)
require.Equal(t, "mixed", message.BodyStructure.MIMESubType)
require.Equal(t, "multipart", message.BodyStructure.Parts[0].MIMEType)
require.Equal(t, "related", message.BodyStructure.Parts[0].MIMESubType)
require.Len(t, message.BodyStructure.Parts[0].Parts, 2)
require.Equal(t, "text", message.BodyStructure.Parts[0].Parts[0].MIMEType)
require.Equal(t, "html", message.BodyStructure.Parts[0].Parts[0].MIMESubType)
require.Equal(t, "image", message.BodyStructure.Parts[0].Parts[1].MIMEType)
require.Equal(t, "jpeg", message.BodyStructure.Parts[0].Parts[1].MIMESubType)
}
return true
}, 10*time.Second, 100*time.Millisecond)
})
})
}

View File

@ -37,6 +37,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/events"
"github.com/ProtonMail/proton-bridge/v3/internal/services/imapservice"
"github.com/bradenaw/juniper/iterator"
"github.com/bradenaw/juniper/stream"
"github.com/bradenaw/juniper/xslices"
@ -579,6 +580,67 @@ func TestBridge_MessageCreateDuringSync(t *testing.T) {
}, server.WithTLS(false))
}
func TestBridge_CorruptedVaultClearsPreviousIMAPSyncState(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) {
userID, addrID, err := s.CreateUser("imap", password)
require.NoError(t, err)
labelID, err := s.CreateLabel(userID, "folder", "", proton.LabelTypeFolder)
require.NoError(t, err)
withClient(ctx, t, s, "imap", password, func(ctx context.Context, c *proton.Client) {
createNumMessages(ctx, t, c, addrID, labelID, 100)
})
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
syncCh, done := chToType[events.Event, events.SyncFinished](bridge.GetEvents(events.SyncFinished{}))
defer done()
var err error
userID, err = bridge.LoginFull(context.Background(), "imap", password, nil, nil)
require.NoError(t, err)
// Wait for sync to finish
require.Equal(t, userID, (<-syncCh).UserID)
})
settingsPath, err := locator.ProvideSettingsPath()
require.NoError(t, err)
syncConfigPath, err := locator.ProvideIMAPSyncConfigPath()
require.NoError(t, err)
syncStatePath := imapservice.GetSyncConfigPath(syncConfigPath, userID)
// Check sync state is complete
{
state, err := imapservice.NewSyncState(syncStatePath)
require.NoError(t, err)
syncStatus, err := state.GetSyncStatus(context.Background())
require.NoError(t, err)
require.True(t, syncStatus.IsComplete())
}
// corrupt the vault
require.NoError(t, os.WriteFile(filepath.Join(settingsPath, "vault.enc"), []byte("Trash!"), 0o600))
// Bridge starts but can't find the gluon database dir; there should be no error.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
_, err := bridge.LoginFull(context.Background(), "imap", password, nil, nil)
require.NoError(t, err)
})
// Check sync state is reset.
{
state, err := imapservice.NewSyncState(syncStatePath)
require.NoError(t, err)
syncStatus, err := state.GetSyncStatus(context.Background())
require.NoError(t, err)
require.False(t, syncStatus.IsComplete())
}
})
}
func withClient(ctx context.Context, t *testing.T, s *server.Server, username string, password []byte, fn func(context.Context, *proton.Client)) { //nolint:unparam
m := proton.New(
proton.WithHostURL(s.GetHostURL()),

View File

@ -53,4 +53,5 @@ type Autostarter interface {
type Updater interface {
GetVersionInfo(context.Context, updater.Downloader, updater.Channel) (updater.VersionInfo, error)
InstallUpdate(context.Context, updater.Downloader, updater.VersionInfo) error
RemoveOldUpdates() error
}

View File

@ -139,3 +139,9 @@ func (bridge *Bridge) installUpdate(ctx context.Context, job installJob) {
}
}, bridge.newVersionLock)
}
func (bridge *Bridge) RemoveOldUpdates() {
if err := bridge.updater.RemoveOldUpdates(); err != nil {
logrus.WithError(err).Error("Remove old updates fails")
}
}

View File

@ -46,6 +46,8 @@ const (
Connected
)
var ErrFailedToUnlock = errors.New("failed to unlock user keys")
type UserInfo struct {
// UserID is the user's API ID.
UserID string
@ -66,10 +68,10 @@ type UserInfo struct {
BridgePass []byte
// UsedSpace is the amount of space used by the user.
UsedSpace int
UsedSpace uint64
// MaxSpace is the total amount of space available to the user.
MaxSpace int
MaxSpace uint64
}
// GetUserIDs returns the IDs of all known users (authorized or not).
@ -157,11 +159,15 @@ func (bridge *Bridge) LoginUser(
func() (string, error) {
return bridge.loginUser(ctx, client, auth.UID, auth.RefreshToken, keyPass)
},
func() error {
return client.AuthDelete(ctx)
},
)
if err != nil {
// 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")
}
}
return "", fmt.Errorf("failed to login user: %w", err)
}
@ -217,7 +223,16 @@ func (bridge *Bridge) LoginFull(
keyPass = password
}
return bridge.LoginUser(ctx, client, auth, keyPass)
userID, err := bridge.LoginUser(ctx, client, auth, keyPass)
if err != nil {
if deleteErr := client.AuthDelete(ctx); deleteErr != nil {
logrus.WithError(err).Error("Failed to delete auth")
}
return "", err
}
return userID, nil
}
// LogoutUser logs out the given user.
@ -314,7 +329,7 @@ func (bridge *Bridge) SetAddressMode(ctx context.Context, userID string, mode va
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")
return safe.LockRet(func() error {
return safe.RLockRet(func() error {
ctx := context.Background()
user, ok := bridge.users[userID]
@ -374,9 +389,9 @@ func (bridge *Bridge) loginUser(ctx context.Context, client *proton.Client, auth
}
if userKR, err := apiUser.Keys.Unlock(saltedKeyPass, nil); err != nil {
return "", fmt.Errorf("failed to unlock user keys: %w", err)
return "", fmt.Errorf("%w: %w", ErrFailedToUnlock, err)
} else if userKR.CountDecryptionEntities() == 0 {
return "", fmt.Errorf("failed to unlock user keys")
return "", ErrFailedToUnlock
}
if err := bridge.addUser(ctx, client, apiUser, authUID, authRef, saltedKeyPass, true); err != nil {

View File

@ -49,7 +49,7 @@ func (bridge *Bridge) handleUserDeauth(ctx context.Context, user *user.User) {
}
func (bridge *Bridge) handleUserBadEvent(ctx context.Context, user *user.User, event events.UserBadEvent) {
safe.Lock(func() {
safe.RLock(func() {
if rerr := bridge.reporter.ReportMessageWithContext("Failed to handle event", reporter.Context{
"user_id": user.ID(),
"old_event_id": event.OldEventID,

View File

@ -95,6 +95,13 @@ func (status *ConfigurationStatus) IsPending() bool {
return !status.Data.DataV1.PendingSince.IsZero()
}
func (status *ConfigurationStatus) isPendingSinceMin() int {
if min := int(time.Since(status.Data.DataV1.PendingSince).Minutes()); min > 0 {
return min
}
return 0
}
func (status *ConfigurationStatus) IsFromFailure() bool {
status.DataLock.RLock()
defer status.DataLock.RUnlock()

View File

@ -19,7 +19,6 @@ package configstatus
import (
"strconv"
"time"
)
type ConfigAbortValues struct {
@ -41,17 +40,20 @@ type ConfigAbortData struct {
type ConfigAbortBuilder struct{}
func (*ConfigAbortBuilder) New(data *ConfigurationStatusData) ConfigAbortData {
func (*ConfigAbortBuilder) New(config *ConfigurationStatus) ConfigAbortData {
config.DataLock.RLock()
defer config.DataLock.RUnlock()
return ConfigAbortData{
MeasurementGroup: "bridge.any.configuration",
Event: "bridge_config_abort",
Values: ConfigSuccessValues{
Duration: int(time.Since(data.DataV1.PendingSince).Minutes()),
Duration: config.isPendingSinceMin(),
},
Dimensions: ConfigSuccessDimensions{
ReportClick: strconv.FormatBool(data.DataV1.ReportClick),
ReportSent: strconv.FormatBool(data.DataV1.ReportSent),
ClickedLink: data.clickedLinkToString(),
ReportClick: strconv.FormatBool(config.Data.DataV1.ReportClick),
ReportSent: strconv.FormatBool(config.Data.DataV1.ReportSent),
ClickedLink: config.Data.clickedLinkToString(),
},
}
}

View File

@ -33,7 +33,7 @@ func TestConfigurationAbort_default(t *testing.T) {
require.NoError(t, err)
var builder = configstatus.ConfigAbortBuilder{}
req := builder.New(config.Data)
req := builder.New(config)
require.Equal(t, "bridge.any.configuration", req.MeasurementGroup)
require.Equal(t, "bridge_config_abort", req.Event)
@ -64,7 +64,7 @@ func TestConfigurationAbort_fed(t *testing.T) {
require.NoError(t, err)
var builder = configstatus.ConfigAbortBuilder{}
req := builder.New(config.Data)
req := builder.New(config)
require.Equal(t, "bridge.any.configuration", req.MeasurementGroup)
require.Equal(t, "bridge_config_abort", req.Event)

View File

@ -33,13 +33,16 @@ type ConfigProgressData struct {
type ConfigProgressBuilder struct{}
func (*ConfigProgressBuilder) New(data *ConfigurationStatusData) ConfigProgressData {
func (*ConfigProgressBuilder) New(config *ConfigurationStatus) ConfigProgressData {
config.DataLock.RLock()
defer config.DataLock.RUnlock()
return ConfigProgressData{
MeasurementGroup: "bridge.any.configuration",
Event: "bridge_config_progress",
Values: ConfigProgressValues{
NbDay: numberOfDay(time.Now(), data.DataV1.PendingSince),
NbDaySinceLast: numberOfDay(time.Now(), data.DataV1.LastProgress),
NbDay: numberOfDay(time.Now(), config.Data.DataV1.PendingSince),
NbDaySinceLast: numberOfDay(time.Now(), config.Data.DataV1.LastProgress),
},
}
}

View File

@ -33,7 +33,7 @@ func TestConfigurationProgress_default(t *testing.T) {
require.NoError(t, err)
var builder = configstatus.ConfigProgressBuilder{}
req := builder.New(config.Data)
req := builder.New(config)
require.Equal(t, "bridge.any.configuration", req.MeasurementGroup)
require.Equal(t, "bridge_config_progress", req.Event)
@ -62,7 +62,7 @@ func TestConfigurationProgress_fed(t *testing.T) {
require.NoError(t, err)
var builder = configstatus.ConfigProgressBuilder{}
req := builder.New(config.Data)
req := builder.New(config)
require.Equal(t, "bridge.any.configuration", req.MeasurementGroup)
require.Equal(t, "bridge_config_progress", req.Event)

View File

@ -19,7 +19,6 @@ package configstatus
import (
"strconv"
"time"
)
type ConfigRecoveryValues struct {
@ -43,19 +42,22 @@ type ConfigRecoveryData struct {
type ConfigRecoveryBuilder struct{}
func (*ConfigRecoveryBuilder) New(data *ConfigurationStatusData) ConfigRecoveryData {
func (*ConfigRecoveryBuilder) New(config *ConfigurationStatus) ConfigRecoveryData {
config.DataLock.RLock()
defer config.DataLock.RUnlock()
return ConfigRecoveryData{
MeasurementGroup: "bridge.any.configuration",
Event: "bridge_config_recovery",
Values: ConfigRecoveryValues{
Duration: int(time.Since(data.DataV1.PendingSince).Minutes()),
Duration: config.isPendingSinceMin(),
},
Dimensions: ConfigRecoveryDimensions{
Autoconf: data.DataV1.Autoconf,
ReportClick: strconv.FormatBool(data.DataV1.ReportClick),
ReportSent: strconv.FormatBool(data.DataV1.ReportSent),
ClickedLink: data.clickedLinkToString(),
FailureDetails: data.DataV1.FailureDetails,
Autoconf: config.Data.DataV1.Autoconf,
ReportClick: strconv.FormatBool(config.Data.DataV1.ReportClick),
ReportSent: strconv.FormatBool(config.Data.DataV1.ReportSent),
ClickedLink: config.Data.clickedLinkToString(),
FailureDetails: config.Data.DataV1.FailureDetails,
},
}
}

View File

@ -33,7 +33,7 @@ func TestConfigurationRecovery_default(t *testing.T) {
require.NoError(t, err)
var builder = configstatus.ConfigRecoveryBuilder{}
req := builder.New(config.Data)
req := builder.New(config)
require.Equal(t, "bridge.any.configuration", req.MeasurementGroup)
require.Equal(t, "bridge_config_recovery", req.Event)
@ -66,7 +66,7 @@ func TestConfigurationRecovery_fed(t *testing.T) {
require.NoError(t, err)
var builder = configstatus.ConfigRecoveryBuilder{}
req := builder.New(config.Data)
req := builder.New(config)
require.Equal(t, "bridge.any.configuration", req.MeasurementGroup)
require.Equal(t, "bridge_config_recovery", req.Event)

View File

@ -19,7 +19,6 @@ package configstatus
import (
"strconv"
"time"
)
type ConfigSuccessValues struct {
@ -42,18 +41,21 @@ type ConfigSuccessData struct {
type ConfigSuccessBuilder struct{}
func (*ConfigSuccessBuilder) New(data *ConfigurationStatusData) ConfigSuccessData {
func (*ConfigSuccessBuilder) New(config *ConfigurationStatus) ConfigSuccessData {
config.DataLock.RLock()
defer config.DataLock.RUnlock()
return ConfigSuccessData{
MeasurementGroup: "bridge.any.configuration",
Event: "bridge_config_success",
Values: ConfigSuccessValues{
Duration: int(time.Since(data.DataV1.PendingSince).Minutes()),
Duration: config.isPendingSinceMin(),
},
Dimensions: ConfigSuccessDimensions{
Autoconf: data.DataV1.Autoconf,
ReportClick: strconv.FormatBool(data.DataV1.ReportClick),
ReportSent: strconv.FormatBool(data.DataV1.ReportSent),
ClickedLink: data.clickedLinkToString(),
Autoconf: config.Data.DataV1.Autoconf,
ReportClick: strconv.FormatBool(config.Data.DataV1.ReportClick),
ReportSent: strconv.FormatBool(config.Data.DataV1.ReportSent),
ClickedLink: config.Data.clickedLinkToString(),
},
}
}

View File

@ -33,7 +33,7 @@ func TestConfigurationSuccess_default(t *testing.T) {
require.NoError(t, err)
var builder = configstatus.ConfigSuccessBuilder{}
req := builder.New(config.Data)
req := builder.New(config)
require.Equal(t, "bridge.any.configuration", req.MeasurementGroup)
require.Equal(t, "bridge_config_success", req.Event)
@ -65,7 +65,7 @@ func TestConfigurationSuccess_fed(t *testing.T) {
require.NoError(t, err)
var builder = configstatus.ConfigSuccessBuilder{}
req := builder.New(config.Data)
req := builder.New(config)
require.Equal(t, "bridge.any.configuration", req.MeasurementGroup)
require.Equal(t, "bridge_config_success", req.Event)

View File

@ -175,7 +175,7 @@ type UsedSpaceChanged struct {
UserID string
UsedSpace int
UsedSpace uint64
}
func (event UsedSpaceChanged) String() string {

View File

@ -368,7 +368,7 @@ Item {
currentIndex: hasAccount() ? 1 : 0
NoAccountView {
colorScheme: root.colorScheme
onLinkClicked: function() {
onStartSetup: {
root.showLogin("")
}
}

View File

@ -23,7 +23,7 @@ Rectangle {
color: root.colorScheme.background_norm
signal linkClicked()
signal startSetup()
ColumnLayout {
anchors.fill: parent
@ -38,8 +38,10 @@ Rectangle {
wizard: setupWizard
Component.onCompleted: {
showOnboarding();
link1.setCallback(root.linkClicked, "Start setup", false)
showNoAccount();
}
onStartSetup: {
root.startSetup();
}
}
Image {

View File

@ -728,10 +728,12 @@ QtObject {
}
property Notification noKeychain: Notification {
brief: title
description: qsTr("Bridge is not able to detect a supported password manager (pass or secret-service). Please install and setup supported password manager and restart the application.")
description: Backend.goos === "darwin" ?
qsTr("Bridge is not able to access your keychain. Please make sure your keychain is not locked and restart the application.") :
qsTr("Bridge is not able to detect a supported password manager (pass or secret-service). Please install and setup supported password manager and restart the application.")
group: Notifications.Group.Dialogs | Notifications.Group.Configuration
icon: "./icons/ic-exclamation-circle-filled.svg"
title: qsTr("No keychain available")
title: Backend.goos === "darwin" ? qsTr("Cannot access keychain") : qsTr("No keychain available")
type: Notification.NotificationType.Danger
action: [

View File

@ -114,6 +114,9 @@ FocusScope {
function getText(start, end) {
control.getText(start, end);
}
function hidePassword() {
eyeButton.checked = false;
}
function insert(position, text) {
control.insert(position, text);
}
@ -147,6 +150,9 @@ FocusScope {
function selectWord() {
control.selectWord();
}
function showPassword() {
eyeButton.checked = true;
}
function undo() {
control.undo();
}

View File

@ -18,14 +18,21 @@ import QtQuick.Controls
Item {
id: root
readonly property string addAccountTitle: qsTr("Add a Proton Mail account")
readonly property string welcomeDescription: qsTr("Bridge is the gateway between your Proton account and your email client. It runs in the background and encrypts and decrypts your messages seamlessly. ");
readonly property string welcomeTitle: qsTr("Welcome to\nProton Mail Bridge")
readonly property string welcomeImage: "/qml/icons/img-welcome.svg"
readonly property int welcomeImageHeight: 148;
readonly property int welcomeImageWidth: 265;
property int iconHeight
property string iconSource
property int iconWidth
property var wizard
property ColorScheme colorScheme
property var _colorScheme: wizard ? wizard.colorScheme : colorScheme
property var link1: linkLabel1
property var link2: linkLabel2
signal startSetup()
function showAppleMailAutoconfigCertificateInstall() {
showAppleMailAutoconfigCommon();
@ -65,26 +72,27 @@ Item {
function showLoginMailboxPassword() {
showOnboarding();
}
function showNoAccount() {
titleLabel.text = welcomeTitle;
descriptionLabel.text = welcomeDescription;
linkLabel1.setCallback(startSetup, "Start setup", false);
linkLabel2.clear();
root.iconSource = welcomeImage;
root.iconHeight = welcomeImageHeight;
root.iconWidth = welcomeImageWidth;
}
function showOnboarding() {
titleLabel.text = (Backend.users.count === 0) ? qsTr("Welcome to\nProton Mail Bridge") : qsTr("Add a Proton Mail account");
descriptionLabel.text = qsTr("Bridge is the gateway between your Proton account and your email client. It runs in the background and encrypts and decrypts your messages seamlessly. ");
titleLabel.text = (Backend.users.count === 0) ? welcomeTitle : addAccountTitle;
descriptionLabel.text = welcomeDescription
linkLabel1.setCallback(function() { Backend.openKBArticle("https://proton.me/support/why-you-need-bridge"); }, qsTr("Why do I need Bridge?"), true);
linkLabel2.clear();
root.iconSource = "/qml/icons/img-welcome.svg";
root.iconHeight = 148;
root.iconWidth = 265;
root.iconSource = welcomeImage;
root.iconHeight = welcomeImageHeight;
root.iconWidth = welcomeImageWidth;
}
Connections {
function onLogin2FARequested() {
showLogin2FA();
}
function onLogin2PasswordRequested() {
showLoginMailboxPassword();
}
target: Backend
}
ColumnLayout {
anchors.left: parent.left
anchors.right: parent.right

View File

@ -44,6 +44,8 @@ FocusScope {
} else {
passwordTextField.forceActiveFocus();
}
passwordTextField.hidePassword();
secondPasswordTextField.hidePassword();
}
StackLayout {

View File

@ -186,6 +186,17 @@ Item {
target: clientConfigAppleMail
}
Connections {
function onLogin2FARequested() {
leftContent.showLogin2FA();
}
function onLogin2PasswordRequested() {
leftContent.showLoginMailboxPassword();
}
target: Backend
}
}
Image {
id: mailLogoWithWordmark

View File

@ -309,6 +309,8 @@ void User::setIsSyncing(bool syncing) {
}
isSyncing_ = syncing;
syncProgress_ = 0;
emit isSyncingChanged(syncing);
}

View File

@ -193,7 +193,7 @@ func NewUserBadEvent(userID string, errorMessage string) *StreamEvent {
return userEvent(&UserEvent{Event: &UserEvent_UserBadEvent{UserBadEvent: &UserBadEvent{UserID: userID, ErrorMessage: errorMessage}}})
}
func NewUsedBytesChangedEvent(userID string, usedBytes int) *StreamEvent {
func NewUsedBytesChangedEvent(userID string, usedBytes uint64) *StreamEvent {
return userEvent(&UserEvent{Event: &UserEvent_UsedBytesChangedEvent{UsedBytesChangedEvent: &UsedBytesChangedEvent{UserID: userID, UsedBytes: int64(usedBytes)}}})
}

View File

@ -54,8 +54,9 @@ import (
)
const (
serverConfigFileName = "grpcServerConfig.json"
serverTokenMetadataKey = "server-token"
serverConfigFileName = "grpcServerConfig.json"
serverTokenMetadataKey = "server-token"
twoPasswordsMaxAttemptCount = 3 // The number of attempts allowed for the mailbox password.
)
// Service is the RPC service struct.
@ -82,9 +83,10 @@ type Service struct { // nolint:structcheck
target updater.VersionInfo
targetLock safe.RWMutex
authClient *proton.Client
auth proton.Auth
password []byte
authClient *proton.Client
auth proton.Auth
password []byte
twoPasswordAttemptCount int
log *logrus.Entry
initializing sync.WaitGroup
@ -338,6 +340,11 @@ func (s *Service) watchEvents() {
case events.SyncFinished:
_ = s.SendEvent(NewSyncFinishedEvent(event.UserID))
case events.SyncFailed:
if errors.Is(event.Error, context.Canceled) {
_ = s.SendEvent(NewSyncFinishedEvent(event.UserID))
}
case events.SyncProgress:
_ = s.SendEvent(NewSyncProgressEvent(event.UserID, event.Progress, event.Elapsed.Milliseconds(), event.Remaining.Milliseconds()))
@ -408,7 +415,12 @@ func (s *Service) loginClean() {
}
func (s *Service) finishLogin() {
defer s.loginClean()
performCleanup := true
defer func() {
if performCleanup {
s.loginClean()
}
}()
wasSignedOut := s.bridge.HasUser(s.auth.UserID)
@ -426,10 +438,24 @@ func (s *Service) finishLogin() {
eventCh, done := s.bridge.GetEvents(events.UserLoggedIn{})
defer done()
userID, err := s.bridge.LoginUser(context.Background(), s.authClient, s.auth, s.password)
ctx := context.Background()
userID, err := s.bridge.LoginUser(ctx, s.authClient, s.auth, s.password)
if err != nil {
s.log.WithError(err).Errorf("Finish login failed")
_ = s.SendEvent(NewLoginError(LoginErrorType_TWO_PASSWORDS_ABORT, err.Error()))
s.twoPasswordAttemptCount++
errType := LoginErrorType_TWO_PASSWORDS_ABORT
if errors.Is(err, bridge.ErrFailedToUnlock) {
if s.twoPasswordAttemptCount < twoPasswordsMaxAttemptCount {
performCleanup = false
errType = LoginErrorType_TWO_PASSWORDS_ERROR
} else {
if deleteErr := s.authClient.AuthDelete(ctx); deleteErr != nil {
s.log.WithError(deleteErr).Error("Failed to delete auth")
}
}
}
_ = s.SendEvent(NewLoginError(errType, err.Error()))
return
}

View File

@ -339,18 +339,17 @@ func (s *Service) ReportBug(_ context.Context, report *ReportBugRequest) (*empty
defer async.HandlePanic(s.panicHandler)
defer func() { _ = s.SendEvent(NewReportBugFinishedEvent()) }()
if err := s.bridge.ReportBug(
context.Background(),
report.OsType,
report.OsVersion,
report.Title,
report.Description,
report.Address,
report.Address,
report.EmailClient,
report.IncludeLogs,
); err != nil {
reportReq := bridge.ReportBugReq{
OSType: report.OsType,
OSVersion: report.OsVersion,
Title: report.Title,
Description: report.Description,
Username: report.Address,
Email: report.Address,
EmailClient: report.EmailClient,
IncludeLogs: report.IncludeLogs,
}
if err := s.bridge.ReportBug(context.Background(), &reportReq); err != nil {
s.log.WithError(err).Error("Failed to report bug")
_ = s.SendEvent(NewReportBugErrorEvent())
return
@ -384,6 +383,7 @@ func (s *Service) Login(_ context.Context, login *LoginRequest) (*emptypb.Empty,
go func() {
defer async.HandlePanic(s.panicHandler)
s.twoPasswordAttemptCount = 0
password, err := base64Decode(login.Password)
if err != nil {
s.log.WithError(err).Error("Cannot decode password")

View File

@ -63,6 +63,7 @@ type Connector struct {
log *logrus.Entry
sharedCache *SharedCache
syncState *SyncState
}
func NewConnector(
@ -75,6 +76,7 @@ func NewConnector(
panicHandler async.PanicHandler,
telemetry Telemetry,
showAllMail bool,
syncState *SyncState,
) *Connector {
userID := identityState.UserID()
@ -106,6 +108,7 @@ func NewConnector(
}),
sharedCache: NewSharedCached(),
syncState: syncState,
}
}
@ -114,9 +117,35 @@ func (s *Connector) StateClose() {
s.updateCh.CloseAndDiscardQueued()
}
func (s *Connector) Init(_ context.Context, cache connector.IMAPState) error {
func (s *Connector) Init(ctx context.Context, cache connector.IMAPState) error {
s.sharedCache.Set(cache)
return nil
return cache.Write(ctx, func(ctx context.Context, write connector.IMAPStateWrite) error {
rd := s.labels.Read()
defer rd.Close()
mboxes, err := write.GetMailboxesWithoutAttrib(ctx)
if err != nil {
return err
}
// Attempt to fix bug when a vault got corrupted, but the sync state did not get reset leading to
// all labels being written to the root level. If we detect this happened, reset the sync state.
{
applied, err := fixGODT3003Labels(ctx, s.log, mboxes, rd, write)
if err != nil {
return err
}
if applied {
s.log.Debug("Patched folders/labels after GODT-3003 incident, resetting sync state.")
if err := s.syncState.ClearSyncStatus(ctx); err != nil {
return err
}
}
}
return nil
})
}
func (s *Connector) Authorize(ctx context.Context, username string, password []byte) bool {
@ -745,3 +774,41 @@ func (s *Connector) createDraft(ctx context.Context, literal []byte, addrKR *cry
func (s *Connector) publishUpdate(_ context.Context, update imap.Update) {
s.updateCh.Enqueue(update)
}
func fixGODT3003Labels(
ctx context.Context,
log *logrus.Entry,
mboxes []imap.MailboxNoAttrib,
rd labelsRead,
write connector.IMAPStateWrite,
) (bool, error) {
var applied bool
for _, mbox := range mboxes {
lbl, ok := rd.GetLabel(string(mbox.ID))
if !ok {
continue
}
if lbl.Type == proton.LabelTypeFolder {
if mbox.Name[0] != folderPrefix {
log.WithField("labelID", mbox.ID.ShortID()).Debug("Found folder without prefix, patching")
if err := write.PatchMailboxHierarchyWithoutTransforms(ctx, mbox.ID, xslices.Insert(mbox.Name, 0, folderPrefix)); err != nil {
return false, fmt.Errorf("failed to update mailbox name: %w", err)
}
applied = true
}
} else if lbl.Type == proton.LabelTypeLabel {
if mbox.Name[0] != labelPrefix {
log.WithField("labelID", mbox.ID.ShortID()).Debug("Found label without prefix, patching")
if err := write.PatchMailboxHierarchyWithoutTransforms(ctx, mbox.ID, xslices.Insert(mbox.Name, 0, labelPrefix)); err != nil {
return false, fmt.Errorf("failed to update mailbox name: %w", err)
}
applied = true
}
}
}
return applied, nil
}

View File

@ -0,0 +1,205 @@
// Copyright (c) 2023 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 imapservice
import (
"context"
"testing"
"github.com/ProtonMail/gluon/imap"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal/services/imapservice/mocks"
"github.com/golang/mock/gomock"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/require"
)
func TestFixGODT3003Labels(t *testing.T) {
mockCtrl := gomock.NewController(t)
log := logrus.WithField("test", "test")
sharedLabels := newRWLabels()
wr := sharedLabels.Write()
wr.SetLabel("foo", proton.Label{
ID: "foo",
ParentID: "bar",
Name: "Foo",
Path: []string{"bar", "Foo"},
Color: "",
Type: proton.LabelTypeFolder,
})
wr.SetLabel("0", proton.Label{
ID: "0",
ParentID: "",
Name: "Inbox",
Path: []string{"Inbox"},
Color: "",
Type: proton.LabelTypeSystem,
})
wr.SetLabel("bar", proton.Label{
ID: "bar",
ParentID: "",
Name: "boo",
Path: []string{"bar"},
Color: "",
Type: proton.LabelTypeFolder,
})
wr.SetLabel("my_label", proton.Label{
ID: "my_label",
ParentID: "",
Name: "MyLabel",
Path: []string{"MyLabel"},
Color: "",
Type: proton.LabelTypeLabel,
})
wr.SetLabel("my_label2", proton.Label{
ID: "my_label2",
ParentID: "",
Name: "MyLabel2",
Path: []string{labelPrefix, "MyLabel2"},
Color: "",
Type: proton.LabelTypeLabel,
})
wr.Close()
mboxs := []imap.MailboxNoAttrib{
{
ID: "0",
Name: []string{"Inbox"},
},
{
ID: "bar",
Name: []string{"bar"},
},
{
ID: "foo",
Name: []string{"bar", "Foo"},
},
{
ID: "my_label",
Name: []string{"MyLabel"},
},
{
ID: "my_label2",
Name: []string{labelPrefix, "MyLabel2"},
},
}
rd := sharedLabels.Read()
defer rd.Close()
imapState := mocks.NewMockIMAPStateWrite(mockCtrl)
imapState.EXPECT().PatchMailboxHierarchyWithoutTransforms(gomock.Any(), gomock.Eq(imap.MailboxID("bar")), gomock.Eq([]string{folderPrefix, "bar"}))
imapState.EXPECT().PatchMailboxHierarchyWithoutTransforms(gomock.Any(), gomock.Eq(imap.MailboxID("foo")), gomock.Eq([]string{folderPrefix, "bar", "Foo"}))
imapState.EXPECT().PatchMailboxHierarchyWithoutTransforms(gomock.Any(), gomock.Eq(imap.MailboxID("my_label")), gomock.Eq([]string{labelPrefix, "MyLabel"}))
applied, err := fixGODT3003Labels(context.Background(), log, mboxs, rd, imapState)
require.NoError(t, err)
require.True(t, applied)
}
func TestFixGODT3003Labels_Noop(t *testing.T) {
mockCtrl := gomock.NewController(t)
log := logrus.WithField("test", "test")
sharedLabels := newRWLabels()
wr := sharedLabels.Write()
wr.SetLabel("foo", proton.Label{
ID: "foo",
ParentID: "bar",
Name: "Foo",
Path: []string{folderPrefix, "bar", "Foo"},
Color: "",
Type: proton.LabelTypeFolder,
})
wr.SetLabel("0", proton.Label{
ID: "0",
ParentID: "",
Name: "Inbox",
Path: []string{"Inbox"},
Color: "",
Type: proton.LabelTypeSystem,
})
wr.SetLabel("bar", proton.Label{
ID: "bar",
ParentID: "",
Name: "bar",
Path: []string{folderPrefix, "bar"},
Color: "",
Type: proton.LabelTypeFolder,
})
wr.SetLabel("my_label", proton.Label{
ID: "my_label",
ParentID: "",
Name: "MyLabel",
Path: []string{labelPrefix, "MyLabel"},
Color: "",
Type: proton.LabelTypeLabel,
})
wr.SetLabel("my_label2", proton.Label{
ID: "my_label2",
ParentID: "",
Name: "MyLabel2",
Path: []string{labelPrefix, "MyLabel2"},
Color: "",
Type: proton.LabelTypeLabel,
})
wr.Close()
mboxs := []imap.MailboxNoAttrib{
{
ID: "0",
Name: []string{"Inbox"},
},
{
ID: "bar",
Name: []string{folderPrefix, "bar"},
},
{
ID: "foo",
Name: []string{folderPrefix, "bar", "Foo"},
},
{
ID: "my_label",
Name: []string{labelPrefix, "MyLabel"},
},
{
ID: "my_label2",
Name: []string{labelPrefix, "MyLabel2"},
},
}
rd := sharedLabels.Read()
defer rd.Close()
imapState := mocks.NewMockIMAPStateWrite(mockCtrl)
applied, err := fixGODT3003Labels(context.Background(), log, mboxs, rd, imapState)
require.NoError(t, err)
require.False(t, applied)
}

View File

@ -0,0 +1,138 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/ProtonMail/gluon/connector (interfaces: IMAPStateWrite)
// Package mocks is a generated GoMock package.
package mocks
import (
context "context"
reflect "reflect"
imap "github.com/ProtonMail/gluon/imap"
gomock "github.com/golang/mock/gomock"
)
// MockIMAPStateWrite is a mock of IMAPStateWrite interface.
type MockIMAPStateWrite struct {
ctrl *gomock.Controller
recorder *MockIMAPStateWriteMockRecorder
}
// MockIMAPStateWriteMockRecorder is the mock recorder for MockIMAPStateWrite.
type MockIMAPStateWriteMockRecorder struct {
mock *MockIMAPStateWrite
}
// NewMockIMAPStateWrite creates a new mock instance.
func NewMockIMAPStateWrite(ctrl *gomock.Controller) *MockIMAPStateWrite {
mock := &MockIMAPStateWrite{ctrl: ctrl}
mock.recorder = &MockIMAPStateWriteMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockIMAPStateWrite) EXPECT() *MockIMAPStateWriteMockRecorder {
return m.recorder
}
// CreateMailbox mocks base method.
func (m *MockIMAPStateWrite) CreateMailbox(arg0 context.Context, arg1 imap.Mailbox) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CreateMailbox", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// CreateMailbox indicates an expected call of CreateMailbox.
func (mr *MockIMAPStateWriteMockRecorder) CreateMailbox(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateMailbox", reflect.TypeOf((*MockIMAPStateWrite)(nil).CreateMailbox), arg0, arg1)
}
// GetMailboxCount mocks base method.
func (m *MockIMAPStateWrite) GetMailboxCount(arg0 context.Context) (int, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetMailboxCount", arg0)
ret0, _ := ret[0].(int)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetMailboxCount indicates an expected call of GetMailboxCount.
func (mr *MockIMAPStateWriteMockRecorder) GetMailboxCount(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMailboxCount", reflect.TypeOf((*MockIMAPStateWrite)(nil).GetMailboxCount), arg0)
}
// GetMailboxesWithoutAttrib mocks base method.
func (m *MockIMAPStateWrite) GetMailboxesWithoutAttrib(arg0 context.Context) ([]imap.MailboxNoAttrib, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetMailboxesWithoutAttrib", arg0)
ret0, _ := ret[0].([]imap.MailboxNoAttrib)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetMailboxesWithoutAttrib indicates an expected call of GetMailboxesWithoutAttrib.
func (mr *MockIMAPStateWriteMockRecorder) GetMailboxesWithoutAttrib(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMailboxesWithoutAttrib", reflect.TypeOf((*MockIMAPStateWrite)(nil).GetMailboxesWithoutAttrib), arg0)
}
// GetSettings mocks base method.
func (m *MockIMAPStateWrite) GetSettings(arg0 context.Context) (string, bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetSettings", arg0)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(bool)
ret2, _ := ret[2].(error)
return ret0, ret1, ret2
}
// GetSettings indicates an expected call of GetSettings.
func (mr *MockIMAPStateWriteMockRecorder) GetSettings(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSettings", reflect.TypeOf((*MockIMAPStateWrite)(nil).GetSettings), arg0)
}
// PatchMailboxHierarchyWithoutTransforms mocks base method.
func (m *MockIMAPStateWrite) PatchMailboxHierarchyWithoutTransforms(arg0 context.Context, arg1 imap.MailboxID, arg2 []string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "PatchMailboxHierarchyWithoutTransforms", arg0, arg1, arg2)
ret0, _ := ret[0].(error)
return ret0
}
// PatchMailboxHierarchyWithoutTransforms indicates an expected call of PatchMailboxHierarchyWithoutTransforms.
func (mr *MockIMAPStateWriteMockRecorder) PatchMailboxHierarchyWithoutTransforms(arg0, arg1, arg2 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PatchMailboxHierarchyWithoutTransforms", reflect.TypeOf((*MockIMAPStateWrite)(nil).PatchMailboxHierarchyWithoutTransforms), arg0, arg1, arg2)
}
// StoreSettings mocks base method.
func (m *MockIMAPStateWrite) StoreSettings(arg0 context.Context, arg1 string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "StoreSettings", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// StoreSettings indicates an expected call of StoreSettings.
func (mr *MockIMAPStateWriteMockRecorder) StoreSettings(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StoreSettings", reflect.TypeOf((*MockIMAPStateWrite)(nil).StoreSettings), arg0, arg1)
}
// UpdateMessageFlags mocks base method.
func (m *MockIMAPStateWrite) UpdateMessageFlags(arg0 context.Context, arg1 imap.MessageID, arg2 imap.FlagSet) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateMessageFlags", arg0, arg1, arg2)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateMessageFlags indicates an expected call of UpdateMessageFlags.
func (mr *MockIMAPStateWriteMockRecorder) UpdateMessageFlags(arg0, arg1, arg2 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateMessageFlags", reflect.TypeOf((*MockIMAPStateWrite)(nil).UpdateMessageFlags), arg0, arg1, arg2)
}

View File

@ -22,6 +22,7 @@ import (
"errors"
"fmt"
"path/filepath"
"sync/atomic"
"time"
"github.com/ProtonMail/gluon/async"
@ -94,7 +95,7 @@ type Service struct {
syncConfigPath string
lastHandledEventID string
isSyncing bool
isSyncing atomic.Bool
}
func NewService(
@ -151,14 +152,14 @@ func NewService(
connectors: make(map[string]*Connector),
maxSyncMemory: maxSyncMemory,
eventWatcher: subscription.Add(events.IMAPServerCreated{}),
eventWatcher: subscription.Add(events.IMAPServerCreated{}, events.ConnStatusUp{}, events.ConnStatusDown{}),
eventSubscription: subscription,
showAllMail: showAllMail,
syncUpdateApplier: syncUpdateApplier,
syncMessageBuilder: syncMessageBuilder,
syncReporter: syncReporter,
syncConfigPath: getSyncConfigPath(syncConfigDir, identityState.User.ID),
syncConfigPath: GetSyncConfigPath(syncConfigDir, identityState.User.ID),
}
}
@ -217,18 +218,6 @@ func (s *Service) Resync(ctx context.Context) error {
return err
}
func (s *Service) CancelSync(ctx context.Context) error {
_, err := s.cpc.Send(ctx, &cancelSyncReq{})
return err
}
func (s *Service) ResumeSync(ctx context.Context) error {
_, err := s.cpc.Send(ctx, &resumeSyncReq{})
return err
}
func (s *Service) OnBadEvent(ctx context.Context) error {
_, err := s.cpc.Send(ctx, &onBadEventReq{})
@ -341,6 +330,7 @@ func (s *Service) run(ctx context.Context) { //nolint gocyclo
}
switch r := req.Value().(type) {
case *setAddressModeReq:
s.log.Debug("Set Address Mode Request")
err := s.setAddressMode(ctx, r.mode)
req.Reply(ctx, nil, err)
@ -350,38 +340,33 @@ func (s *Service) run(ctx context.Context) { //nolint gocyclo
req.Reply(ctx, nil, err)
s.log.Info("Resync reply sent, handling as refresh event")
case *cancelSyncReq:
s.log.Info("Cancelling sync")
s.syncHandler.Cancel()
req.Reply(ctx, nil, nil)
case *resumeSyncReq:
s.log.Info("Resuming sync")
// Cancel previous run, if any, just in case.
s.cancelSync()
s.startSyncing()
req.Reply(ctx, nil, nil)
case *getLabelsReq:
s.log.Debug("Get labels Request")
labels := s.labels.GetLabelMap()
req.Reply(ctx, labels, nil)
case *onBadEventReq:
s.log.Debug("Bad Event Request")
err := s.removeConnectorsFromServer(ctx, s.connectors, false)
req.Reply(ctx, nil, err)
case *onBadEventResyncReq:
s.log.Debug("Bad Event Resync Request")
err := s.addConnectorsToServer(ctx, s.connectors)
req.Reply(ctx, nil, err)
case *onLogoutReq:
s.log.Debug("Logout Request")
err := s.removeConnectorsFromServer(ctx, s.connectors, false)
req.Reply(ctx, nil, err)
case *showAllMailReq:
s.log.Debug("Show all mail request")
req.Reply(ctx, nil, nil)
s.setShowAllMail(r.v)
case *getSyncFailedMessagesReq:
s.log.Debug("Get sync failed messages Request")
status, err := s.syncStateProvider.GetSyncStatus(ctx)
if err != nil {
req.Reply(ctx, nil, fmt.Errorf("failed to get sync status: %w", err))
@ -405,23 +390,28 @@ func (s *Service) run(ctx context.Context) { //nolint gocyclo
continue
}
// Start a goroutine to wait on event reset as it is possible that the sync received message
// was processed during an event publish. This in turn will block the imap service, since the
// event service is unable to reply to the request until the events have been processed.
s.log.Info("Sync complete, starting API event stream")
if err := s.eventProvider.RewindEventID(ctx, s.lastHandledEventID); err != nil {
if errors.Is(err, context.Canceled) {
continue
go func() {
if err := s.eventProvider.RewindEventID(ctx, s.lastHandledEventID); err != nil {
if errors.Is(err, context.Canceled) {
return
}
s.log.WithError(err).Error("Failed to rewind event service")
s.eventPublisher.PublishEvent(ctx, events.UserBadEvent{
UserID: s.identityState.UserID(),
OldEventID: "",
NewEventID: "",
EventInfo: "",
Error: fmt.Errorf("failed to rewind event loop: %w", err),
})
}
s.log.WithError(err).Error("Failed to rewind event service")
s.eventPublisher.PublishEvent(ctx, events.UserBadEvent{
UserID: s.identityState.UserID(),
OldEventID: "",
NewEventID: "",
EventInfo: "",
Error: fmt.Errorf("failed to rewind event loop: %w", err),
})
}
s.isSyncing = false
s.isSyncing.Store(false)
}()
}
case request, ok := <-s.syncUpdateApplier.requestCh:
@ -443,7 +433,7 @@ func (s *Service) run(ctx context.Context) { //nolint gocyclo
continue
}
e.Consume(func(event proton.Event) error {
if s.isSyncing {
if s.isSyncing.Load() {
if err := syncEventHandler.OnEvent(ctx, event); err != nil {
return err
}
@ -470,10 +460,21 @@ func (s *Service) run(ctx context.Context) { //nolint gocyclo
continue
}
if _, ok := e.(events.IMAPServerCreated); ok {
switch e.(type) {
case events.IMAPServerCreated:
s.log.Debug("On IMAPServerCreated")
if err := s.addConnectorsToServer(ctx, s.connectors); err != nil {
s.log.WithError(err).Error("Failed to add connector to server after created")
}
case events.ConnStatusUp:
s.log.Info("Connection Restored Resuming Sync (if any)")
// Cancel previous run, if any, just in case.
s.cancelSync()
s.startSyncing()
case events.ConnStatusDown:
s.log.Info("Connection Lost cancelling sync")
s.cancelSync()
}
}
}
@ -498,6 +499,7 @@ func (s *Service) buildConnectors() (map[string]*Connector, error) {
s.panicHandler,
s.telemetry,
s.showAllMail,
s.syncStateProvider,
)
return connectors, nil
@ -514,6 +516,7 @@ func (s *Service) buildConnectors() (map[string]*Connector, error) {
s.panicHandler,
s.telemetry,
s.showAllMail,
s.syncStateProvider,
)
}
@ -613,21 +616,17 @@ func (s *Service) setShowAllMail(v bool) {
}
func (s *Service) startSyncing() {
s.isSyncing = true
s.isSyncing.Store(true)
s.syncHandler.Execute(s.syncReporter, s.labels.GetLabelMap(), s.syncUpdateApplier, s.syncMessageBuilder, syncservice.DefaultRetryCoolDown)
}
func (s *Service) cancelSync() {
s.syncHandler.CancelAndWait()
s.isSyncing = false
s.isSyncing.Store(false)
}
type resyncReq struct{}
type cancelSyncReq struct{}
type resumeSyncReq struct{}
type getLabelsReq struct{}
type onBadEventReq struct{}
@ -644,6 +643,6 @@ type setAddressModeReq struct {
type getSyncFailedMessagesReq struct{}
func getSyncConfigPath(path string, userID string) string {
func GetSyncConfigPath(path string, userID string) string {
return filepath.Join(path, fmt.Sprintf("sync-%v", userID))
}

View File

@ -128,6 +128,7 @@ func addNewAddressSplitMode(ctx context.Context, s *Service, addrID string) erro
s.panicHandler,
s.telemetry,
s.showAllMail,
s.syncStateProvider,
)
if err := s.serverManager.AddIMAPUser(ctx, connector, connector.addrID, s.gluonIDProvider, s.syncStateProvider); err != nil {

View File

@ -220,7 +220,7 @@ func (s *SyncState) loadUnsafe() error {
}
func DeleteSyncState(configDir, userID string) error {
path := getSyncConfigPath(configDir, userID)
path := GetSyncConfigPath(configDir, userID)
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
@ -234,7 +234,7 @@ func MigrateVaultSettings(
hasLabels, hasMessages bool,
failedMessageIDs []string,
) (bool, error) {
filePath := getSyncConfigPath(configDir, userID)
filePath := GetSyncConfigPath(configDir, userID)
_, err := os.ReadFile(filePath) //nolint:gosec
if err == nil {

View File

@ -29,7 +29,7 @@ import (
func TestMigrateSyncSettings_AlreadyExists(t *testing.T) {
tmpDir := t.TempDir()
testFile := getSyncConfigPath(tmpDir, "test")
testFile := GetSyncConfigPath(tmpDir, "test")
expected, err := generateTestState(testFile)
require.NoError(t, err)
@ -53,7 +53,7 @@ func TestMigrateSyncSettings_DoesNotExist(t *testing.T) {
require.NoError(t, err)
require.True(t, migrated)
state, err := NewSyncState(getSyncConfigPath(tmpDir, "test"))
state, err := NewSyncState(GetSyncConfigPath(tmpDir, "test"))
require.NoError(t, err)
status, err := state.GetSyncStatus(context.Background())
require.NoError(t, err)

View File

@ -119,6 +119,10 @@ func (s *SyncUpdateApplier) SyncSystemLabelsOnly(ctx context.Context, labels map
continue
}
if label.Type != proton.LabelTypeSystem {
continue
}
for _, c := range connectors {
update := newSystemMailboxCreatedUpdate(imap.MailboxID(label.ID), label.Name)
updates = append(updates, update)

View File

@ -390,6 +390,11 @@ func (sm *Service) handleAddIMAPUserImpl(ctx context.Context,
} else {
log.Info("Creating new IMAP user")
// GODT-3003: Ensure previous IMAP sync state is cleared if we run into code path after vault corruption.
if err := syncStateProvider.ClearSyncStatus(ctx); err != nil {
return fmt.Errorf("failed to reset sync status: %w", err)
}
gluonID, err := sm.imapServer.AddUser(ctx, connector, idProvider.GluonKey())
if err != nil {
return fmt.Errorf("failed to add IMAP user: %w", err)

View File

@ -95,6 +95,11 @@ func (s *Service) smtpSendMail(ctx context.Context, authID string, from string,
// If the message contains a sender, use it instead of the one from the return path.
if sender, ok := getMessageSender(parser); ok {
from = sender
fromAddr, err = s.identityState.GetAddr(from)
if err != nil {
logrus.WithError(err).Errorf("Failed to get identity from sender address %v", sender)
return ErrInvalidReturnPath
}
}
// Load the user's mail settings.
@ -220,7 +225,7 @@ func (s *Service) sendWithKey(
ExternalID: message.ExternalID,
})
if err != nil {
return proton.Message{}, fmt.Errorf("failed to create attachments: %w", err)
return proton.Message{}, fmt.Errorf("failed to create draft: %w", err)
}
attKeys, err := s.createAttachments(ctx, s.client, addrKR, draft.ID, message.Attachments)
@ -315,7 +320,11 @@ func getParentID(
switch len(metadata) {
case 1:
// found exactly one parent
parentID = metadata[0].ID
// We can only reference messages that have been sent or received. If this message is a draft
// it needs to be ignored.
if metadata[0].Flags.Has(proton.MessageFlagSent) || metadata[0].Flags.Has(proton.MessageFlagReceived) {
parentID = metadata[0].ID
}
case 0:
// found no parents
default:

View File

@ -18,11 +18,14 @@
package smtp
import (
"fmt"
"github.com/ProtonMail/gluon/rfc822"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/ProtonMail/proton-bridge/v3/pkg/message"
"github.com/bradenaw/juniper/xslices"
"github.com/sirupsen/logrus"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
)
@ -43,6 +46,9 @@ func createSendReq(
}
if recs := recipients.scheme(proton.InternalScheme, proton.ClearScheme, proton.PGPInlineScheme); len(recs) > 0 {
if recs := recipients.scheme(proton.PGPInlineScheme); len(recs) > 0 {
logrus.WithFields(logrus.Fields{"service": "smtp", "settings": "recipient"}).Warn("PGPInline scheme used. Planed to be deprecated.")
}
if recs := recs.content(rfc822.TextHTML); len(recs) > 0 {
if err := req.AddTextPackage(kr, string(richBody), rfc822.TextHTML, recs, attKeys); err != nil {
return proton.SendDraftReq{}, err
@ -54,6 +60,10 @@ func createSendReq(
return proton.SendDraftReq{}, err
}
}
if recs := recs.content(rfc822.MultipartMixed); len(recs) > 0 {
return proton.SendDraftReq{}, fmt.Errorf("invalid MIME type for MIME package: %s", rfc822.MultipartMixed)
}
}
return req, nil

File diff suppressed because it is too large Load Diff

View File

@ -24,6 +24,7 @@ import (
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
const (
@ -547,6 +548,7 @@ func (b *sendPrefsBuilder) setEncryptionPreferences(mailSettings proton.MailSett
// Otherwise keep the defined value.
switch mailSettings.PGPScheme {
case proton.PGPInlineScheme:
logrus.WithFields(logrus.Fields{"service": "smtp", "settings": "account"}).Warn("PGPInline scheme used. Planed to be deprecated.")
b.withSchemeDefault(pgpInline)
case proton.PGPMIMEScheme:
b.withSchemeDefault(pgpMIME)

View File

@ -296,7 +296,7 @@ func (m *MockUserUsedSpaceEventHandler) EXPECT() *MockUserUsedSpaceEventHandlerM
}
// HandleUsedSpaceEvent mocks base method.
func (m *MockUserUsedSpaceEventHandler) HandleUsedSpaceEvent(arg0 context.Context, arg1 int) error {
func (m *MockUserUsedSpaceEventHandler) HandleUsedSpaceEvent(arg0 context.Context, arg1 int64) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "HandleUsedSpaceEvent", arg0, arg1)
ret0, _ := ret[0].(error)

View File

@ -29,6 +29,7 @@ import (
"time"
"github.com/ProtonMail/gluon/async"
"github.com/ProtonMail/gluon/watcher"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
@ -67,6 +68,8 @@ type Service struct {
eventPollWaiters []*EventPollWaiter
eventPollWaitersLock sync.Mutex
eventSubscription events.Subscription
eventWatcher *watcher.Watcher[events.Event]
}
func NewService(
@ -78,6 +81,7 @@ func NewService(
jitter time.Duration,
eventTimeout time.Duration,
panicHandler async.PanicHandler,
eventSubscription events.Subscription,
) *Service {
return &Service{
cpc: cpc.NewCPC(),
@ -88,11 +92,13 @@ func NewService(
"service": "user-events",
"user": userID,
}),
eventPublisher: eventPublisher,
timer: proton.NewTicker(pollPeriod, jitter, panicHandler),
paused: 1,
eventTimeout: eventTimeout,
panicHandler: panicHandler,
eventPublisher: eventPublisher,
timer: proton.NewTicker(pollPeriod, jitter, panicHandler),
paused: 1,
eventTimeout: eventTimeout,
panicHandler: panicHandler,
eventSubscription: eventSubscription,
eventWatcher: eventSubscription.Add(events.ConnStatusDown{}, events.ConnStatusUp{}),
}
}
@ -224,6 +230,19 @@ func (s *Service) run(ctx context.Context, lastEventID string) {
}
continue
case e, ok := <-s.eventWatcher.GetChannel():
if !ok {
continue
}
switch e.(type) {
case events.ConnStatusDown:
s.log.Info("Connection Lost, pausing")
s.Pause()
case events.ConnStatusUp:
s.log.Info("Connection Restored, resuming")
s.Resume()
}
}
// Apply any pending subscription changes.
@ -295,6 +314,11 @@ func (s *Service) run(ctx context.Context, lastEventID string) {
// Close should be called after the service has been cancelled to clean up any remaining pending operations.
func (s *Service) Close() {
if s.eventSubscription != nil {
s.eventSubscription.Remove(s.eventWatcher)
s.eventSubscription = nil
}
s.pendingSubscriptionsLock.Lock()
defer s.pendingSubscriptionsLock.Unlock()

View File

@ -48,6 +48,7 @@ func TestServiceHandleEventError_SubscriberEventUnwrapping(t *testing.T) {
time.Millisecond,
time.Second,
async.NoopPanicHandler{},
events.NewNullSubscription(),
)
lastEventID := "PrevEvent"
@ -85,6 +86,7 @@ func TestServiceHandleEventError_BadEventPutsServiceOnPause(t *testing.T) {
time.Millisecond,
time.Second,
async.NoopPanicHandler{},
events.NewNullSubscription(),
)
service.Resume()
lastEventID := "PrevEvent"
@ -118,6 +120,7 @@ func TestServiceHandleEventError_BadEventFromPublishTimeout(t *testing.T) {
time.Millisecond,
time.Second,
async.NoopPanicHandler{},
events.NewNullSubscription(),
)
lastEventID := "PrevEvent"
event := proton.Event{EventID: "MyEvent"}
@ -148,6 +151,7 @@ func TestServiceHandleEventError_NoBadEventCheck(t *testing.T) {
time.Millisecond,
time.Second,
async.NoopPanicHandler{},
events.NewNullSubscription(),
)
lastEventID := "PrevEvent"
event := proton.Event{EventID: "MyEvent"}
@ -173,6 +177,7 @@ func TestServiceHandleEventError_JsonUnmarshalEventProducesUncategorizedErrorEve
time.Millisecond,
time.Second,
async.NoopPanicHandler{},
events.NewNullSubscription(),
)
lastEventID := "PrevEvent"
event := proton.Event{EventID: "MyEvent"}

View File

@ -26,6 +26,7 @@ import (
"github.com/ProtonMail/gluon/async"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/events/mocks"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/require"
@ -67,6 +68,7 @@ func TestServiceHandleEvent_CheckEventCategoriesHandledInOrder(t *testing.T) {
time.Millisecond,
10*time.Second,
async.NoopPanicHandler{},
events.NewNullSubscription(),
)
subscription := NewCallbackSubscriber("test", EventHandler{
@ -84,7 +86,7 @@ func TestServiceHandleEvent_CheckEventCategoriesHandledInOrder(t *testing.T) {
require.NoError(t, service.handleEvent(context.Background(), "", proton.Event{Refresh: proton.RefreshMail}))
// Simulate Regular event.
usedSpace := 20
usedSpace := int64(20)
require.NoError(t, service.handleEvent(context.Background(), "", proton.Event{
User: new(proton.User),
Addresses: []proton.AddressEvent{
@ -127,6 +129,7 @@ func TestServiceHandleEvent_CheckEventFailureCausesError(t *testing.T) {
time.Millisecond,
time.Second,
async.NoopPanicHandler{},
events.NewNullSubscription(),
)
subscription := NewCallbackSubscriber("test", EventHandler{
@ -164,6 +167,7 @@ func TestServiceHandleEvent_CheckEventFailureCausesErrorParallel(t *testing.T) {
time.Millisecond,
time.Second,
async.NoopPanicHandler{},
events.NewNullSubscription(),
)
subscription := NewCallbackSubscriber("test", EventHandler{

View File

@ -75,6 +75,7 @@ func TestService_EventIDLoadStore(t *testing.T) {
time.Millisecond,
time.Second,
async.NoopPanicHandler{},
events.NewNullSubscription(),
)
_, err := service.Start(context.Background(), group)
@ -130,6 +131,7 @@ func TestService_RetryEventOnNonCatastrophicFailure(t *testing.T) {
time.Millisecond,
time.Second,
async.NoopPanicHandler{},
events.NewNullSubscription(),
)
service.Subscribe(NewCallbackSubscriber("foo", EventHandler{MessageHandler: subscriber}))
@ -179,6 +181,7 @@ func TestService_OnBadEventServiceIsPaused(t *testing.T) {
time.Millisecond,
time.Second,
async.NoopPanicHandler{},
events.NewNullSubscription(),
)
// Event publisher expectations.
@ -245,6 +248,7 @@ func TestService_UnsubscribeDuringEventHandlingDoesNotCauseDeadlock(t *testing.T
time.Millisecond,
time.Second,
async.NoopPanicHandler{},
events.NewNullSubscription(),
)
subscription := NewCallbackSubscriber("foo", EventHandler{MessageHandler: subscriber})
@ -304,6 +308,7 @@ func TestService_UnsubscribeBeforeHandlingEventIsNotConsideredError(t *testing.T
time.Millisecond,
time.Second,
async.NoopPanicHandler{},
events.NewNullSubscription(),
)
subscription := NewEventSubscriber("Foo")
@ -363,6 +368,7 @@ func TestService_WaitOnEventPublishAfterPause(t *testing.T) {
time.Millisecond,
time.Second,
async.NoopPanicHandler{},
events.NewNullSubscription(),
)
subscriber.EXPECT().HandleMessageEvents(gomock.Any(), gomock.Eq(messageEvents)).Times(1).DoAndReturn(func(_ context.Context, _ []proton.MessageEvent) error {
@ -435,6 +441,7 @@ func TestService_EventRewind(t *testing.T) {
time.Millisecond,
time.Second,
async.NoopPanicHandler{},
events.NewNullSubscription(),
)
_, err := service.Start(context.Background(), group)

View File

@ -98,7 +98,7 @@ type UserEventHandler interface {
}
type UserUsedSpaceEventHandler interface {
HandleUsedSpaceEvent(ctx context.Context, newSpace int) error
HandleUsedSpaceEvent(ctx context.Context, newSpace int64) error
}
type UserSettingsHandler interface {

View File

@ -102,13 +102,13 @@ func (s *Service) CheckAuth(ctx context.Context, email string, password []byte)
})
}
func (s *Service) HandleUsedSpaceEvent(ctx context.Context, newSpace int) error {
func (s *Service) HandleUsedSpaceEvent(ctx context.Context, newSpace int64) error {
s.log.Info("Handling User Space Changed event")
if s.identity.OnUserSpaceChanged(newSpace) {
if s.identity.OnUserSpaceChanged(uint64(newSpace)) {
s.eventPublisher.PublishEvent(ctx, events.UsedSpaceChanged{
UserID: s.identity.User.ID,
UsedSpace: newSpace,
UsedSpace: uint64(newSpace),
})
}

View File

@ -54,7 +54,7 @@ func TestService_OnUserSpaceChanged(t *testing.T) {
// New value, event should be published.
require.NoError(t, service.HandleUsedSpaceEvent(context.Background(), 1024))
require.Equal(t, 1024, service.identity.User.UsedSpace)
require.Equal(t, uint64(1024), service.identity.User.UsedSpace)
}
func TestService_OnRefreshEvent(t *testing.T) {

View File

@ -119,7 +119,7 @@ func (s *State) OnRefreshEvent(ctx context.Context) error {
return nil
}
func (s *State) OnUserSpaceChanged(value int) bool {
func (s *State) OnUserSpaceChanged(value uint64) bool {
if s.User.UsedSpace == value {
return false
}

View File

@ -26,6 +26,7 @@ import (
"github.com/Masterminds/semver/v3"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/ProtonMail/proton-bridge/v3/internal/versioner"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
@ -46,15 +47,17 @@ type Installer interface {
}
type Updater struct {
versioner *versioner.Versioner
installer Installer
verifier *crypto.KeyRing
product string
platform string
}
func NewUpdater(installer Installer, verifier *crypto.KeyRing, product, platform string) *Updater {
func NewUpdater(ver *versioner.Versioner, verifier *crypto.KeyRing, product, platform string) *Updater {
return &Updater{
installer: installer,
versioner: ver,
installer: NewInstaller(ver),
verifier: verifier,
product: product,
platform: platform,
@ -109,6 +112,10 @@ func (u *Updater) InstallUpdate(ctx context.Context, downloader Downloader, upda
return nil
}
func (u *Updater) RemoveOldUpdates() error {
return u.versioner.RemoveOldVersions()
}
// getVersionFileURL returns the URL of the version file.
// For example:
// - https://protonmail.com/download/bridge/version_linux.json

View File

@ -38,7 +38,7 @@ func (user *User) SendConfigStatusSuccess(ctx context.Context) {
}
var builder configstatus.ConfigSuccessBuilder
success := builder.New(user.configStatus.Data)
success := builder.New(user.configStatus)
data, err := json.Marshal(success)
if err != nil {
if err := user.reporter.ReportMessageWithContext("Cannot parse config_success data.", reporter.Context{
@ -69,7 +69,7 @@ func (user *User) SendConfigStatusAbort(ctx context.Context, withTelemetry bool)
return
}
var builder configstatus.ConfigAbortBuilder
abort := builder.New(user.configStatus.Data)
abort := builder.New(user.configStatus)
data, err := json.Marshal(abort)
if err != nil {
if err := user.reporter.ReportMessageWithContext("Cannot parse config_abort data.", reporter.Context{
@ -98,7 +98,7 @@ func (user *User) SendConfigStatusRecovery(ctx context.Context) {
}
var builder configstatus.ConfigRecoveryBuilder
success := builder.New(user.configStatus.Data)
success := builder.New(user.configStatus)
data, err := json.Marshal(success)
if err != nil {
if err := user.reporter.ReportMessageWithContext("Cannot parse config_recovery data.", reporter.Context{
@ -125,7 +125,7 @@ func (user *User) SendConfigStatusProgress(ctx context.Context) {
return
}
var builder configstatus.ConfigProgressBuilder
progress := builder.New(user.configStatus.Data)
progress := builder.New(user.configStatus)
if progress.Values.NbDay == 0 {
return
}

View File

@ -22,6 +22,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
@ -37,6 +38,7 @@ import (
imapservice "github.com/ProtonMail/proton-bridge/v3/internal/services/imapservice"
"github.com/ProtonMail/proton-bridge/v3/internal/usertypes"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
bmessage "github.com/ProtonMail/proton-bridge/v3/pkg/message"
"github.com/bradenaw/juniper/xmaps"
"github.com/bradenaw/juniper/xslices"
"github.com/emersion/go-message"
@ -224,6 +226,55 @@ func (user *User) DebugDownloadMessages(
return nil
}
func TryBuildDebugMessage(path string) error {
meta, err := loadDebugMetadata(path)
if err != nil {
return fmt.Errorf("failed to load metadata: %w", err)
}
body, bodyDecrypted, err := loadDebugBody(path)
if err != nil {
return fmt.Errorf("failed to load body: %w", err)
}
var da []bmessage.DecryptedAttachment
if len(meta.Attachments) != 0 {
d, err := loadAttachments(path, &meta)
if err != nil {
return err
}
da = d
}
decryptedMessage := bmessage.DecryptedMessage{
Msg: proton.Message{
MessageMetadata: meta.MessageMetadata,
Header: meta.Header,
ParsedHeaders: meta.ParsedHeaders,
Body: "",
MIMEType: meta.MIMEType,
Attachments: nil,
},
Body: bytes.Buffer{},
BodyErr: nil,
Attachments: da,
}
if bodyDecrypted {
decryptedMessage.Body.Write(body)
} else {
decryptedMessage.Msg.Body = string(body)
decryptedMessage.BodyErr = fmt.Errorf("body did not decrypt")
}
var rfc822Message bytes.Buffer
if err := bmessage.BuildRFC822Into(nil, &decryptedMessage, defaultMessageJobOpts(), &rfc822Message); err != nil {
return fmt.Errorf("failed to build message: %w", err)
}
return nil
}
func getBodyName(path string) string {
return filepath.Join(path, "body.txt")
}
@ -297,16 +348,16 @@ func decodeSimpleMessage(outPath string, kr *crypto.KeyRing, msg proton.Message)
return nil
}
func writeMetadata(outPath string, msg proton.Message) error {
type CustomMetadata struct {
proton.MessageMetadata
Header string
ParsedHeaders proton.Headers
MIMEType rfc822.MIMEType
Attachments []proton.Attachment
}
type DebugMetadata struct {
proton.MessageMetadata
Header string
ParsedHeaders proton.Headers
MIMEType rfc822.MIMEType
Attachments []proton.Attachment
}
metadata := CustomMetadata{
func writeMetadata(outPath string, msg proton.Message) error {
metadata := DebugMetadata{
MessageMetadata: msg.MessageMetadata,
Header: msg.Header,
ParsedHeaders: msg.ParsedHeaders,
@ -433,3 +484,78 @@ func writeCustomAttachmentPart(
return nil
}
func loadDebugMetadata(dir string) (DebugMetadata, error) {
metadataPath := getMetadataPath(dir)
b, err := os.ReadFile(metadataPath) //nolint:gosec
if err != nil {
return DebugMetadata{}, err
}
var m DebugMetadata
if err := json.Unmarshal(b, &m); err != nil {
return DebugMetadata{}, err
}
return m, nil
}
func loadDebugBody(dir string) ([]byte, bool, error) {
if b, err := os.ReadFile(getBodyName(dir)); err != nil {
if !errors.Is(err, os.ErrNotExist) {
return nil, false, err
}
} else {
return b, true, nil
}
if b, err := os.ReadFile(getBodyNameFailed(dir)); err != nil {
if !errors.Is(err, os.ErrNotExist) {
return nil, false, err
}
} else {
return b, false, nil
}
return nil, false, fmt.Errorf("body is either pgp message, which we can't handle or is missing")
}
func loadAttachments(dir string, meta *DebugMetadata) ([]bmessage.DecryptedAttachment, error) {
attDecrypted := make([]bmessage.DecryptedAttachment, 0, len(meta.Attachments))
for _, a := range meta.Attachments {
data, err := os.ReadFile(getAttachmentPathSuccess(dir, a.ID, a.Name))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("attachment (%v,%v) must have failed to decrypt, we can't do anything since we need the user's keyring", a.ID, a.Name)
}
return nil, fmt.Errorf("failed to load attachment (%v,%v): %w", a.ID, a.Name, err)
}
da := bmessage.DecryptedAttachment{
Packet: nil,
Encrypted: nil,
Data: bytes.Buffer{},
Err: nil,
}
da.Data.Write(data)
attDecrypted = append(attDecrypted, da)
}
return attDecrypted, nil
}
func defaultMessageJobOpts() bmessage.JobOptions {
return bmessage.JobOptions{
IgnoreDecryptionErrors: true, // Whether to ignore decryption errors and create a "custom message" instead.
SanitizeDate: true, // Whether to replace all dates before 1970 with RFC822's birthdate.
AddInternalID: true, // Whether to include MessageID as X-Pm-Internal-Id.
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.
}
}

View File

@ -223,6 +223,7 @@ func newImpl(
EventJitter,
5*time.Minute,
crashHandler,
eventSubscription,
)
addressMode := usertypes.VaultToAddressMode(encVault.AddressMode())
@ -515,7 +516,7 @@ func (user *User) BridgePass() []byte {
}
// UsedSpace returns the total space used by the user on the API.
func (user *User) UsedSpace() int {
func (user *User) UsedSpace() uint64 {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(time.Minute))
defer cancel()
@ -528,7 +529,7 @@ func (user *User) UsedSpace() int {
}
// MaxSpace returns the amount of space the user can use on the API.
func (user *User) MaxSpace() int {
func (user *User) MaxSpace() uint64 {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(time.Minute))
defer cancel()
@ -554,27 +555,6 @@ func (user *User) CheckAuth(email string, password []byte) (string, error) {
return user.identityService.CheckAuth(ctx, email, password)
}
// OnStatusUp is called when the connection goes up.
func (user *User) OnStatusUp(ctx context.Context) {
user.log.Info("Connection is up")
user.eventService.Resume()
if err := user.imapService.ResumeSync(ctx); err != nil {
user.log.WithError(err).Error("Failed to resume sync")
}
}
// OnStatusDown is called when the connection goes down.
func (user *User) OnStatusDown(ctx context.Context) {
user.log.Info("Connection is down")
user.eventService.Pause()
if err := user.imapService.CancelSync(ctx); err != nil {
user.log.WithError(err).Error("Failed to cancel sync")
}
}
// Logout logs the user out from the API.
func (user *User) Logout(ctx context.Context, withAPI bool) error {
user.log.WithField("withAPI", withAPI).Info("Logging out user")

View File

@ -32,6 +32,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/pkg/message/parser"
pmmime "github.com/ProtonMail/proton-bridge/v3/pkg/mime"
"github.com/emersion/go-message"
"github.com/google/uuid"
"github.com/jaytaylor/html2text"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
@ -116,6 +117,10 @@ func parse(p *parser.Parser, allowInvalidAddressLists bool) (Message, error) {
return Message{}, errors.Wrap(err, "failed to convert foreign encodings")
}
if err := patchInlineImages(p); err != nil {
return Message{}, err
}
m, err := parseMessageHeader(p.Root().Header, allowInvalidAddressLists)
if err != nil {
return Message{}, errors.Wrap(err, "failed to parse message header")
@ -142,7 +147,7 @@ func parse(p *parser.Parser, allowInvalidAddressLists bool) (Message, error) {
m.PlainBody = Body(plainBody)
m.MIMEBody = MIMEBody(mimeBody)
mimeType, err := determineMIMEType(p)
mimeType, err := determineBodyMIMEType(p)
if err != nil {
return Message{}, errors.Wrap(err, "failed to get mime type")
}
@ -308,7 +313,7 @@ func collectBodyParts(p *parser.Parser, preferredContentType string) (parser.Par
return bestChoice(childParts, preferredContentType), nil
}).
RegisterRule("text/plain", func(p *parser.Part, visit parser.Visit) (interface{}, error) {
disp, _, err := p.Header.ContentDisposition()
disp, _, err := p.ContentDisposition()
if err != nil {
disp = ""
}
@ -320,7 +325,7 @@ func collectBodyParts(p *parser.Parser, preferredContentType string) (parser.Par
return parser.Parts{p}, nil
}).
RegisterRule("text/html", func(p *parser.Part, visit parser.Visit) (interface{}, error) {
disp, _, err := p.Header.ContentDisposition()
disp, _, err := p.ContentDisposition()
if err != nil {
disp = ""
}
@ -400,7 +405,7 @@ func allPartsHaveContentType(parts parser.Parts, contentType string) bool {
return true
}
func determineMIMEType(p *parser.Parser) (string, error) {
func determineBodyMIMEType(p *parser.Parser) (string, error) {
var isHTML bool
w := p.NewWalker().
@ -409,7 +414,7 @@ func determineMIMEType(p *parser.Parser) (string, error) {
return
})
if err := w.Walk(); err != nil {
if err := w.WalkSkipAttachment(); err != nil {
return "", err
}
@ -547,8 +552,8 @@ func parseAttachment(h message.Header, body []byte) (Attachment, error) {
return Attachment{}, err
}
att.Header = mimeHeader
mimeType, mimeTypeParams, err := pmmime.ParseMediaType(h.Get("Content-Type"))
mimeType, mimeTypeParams, err := h.ContentType()
if err != nil {
return Attachment{}, err
}
@ -558,7 +563,8 @@ func parseAttachment(h message.Header, body []byte) (Attachment, error) {
// Prefer attachment name from filename param in content disposition.
// If not available, try to get it from name param in content type.
// Otherwise fallback to attachment.bin.
if disp, dispParams, err := h.ContentDisposition(); err == nil {
disp, dispParams, err := pmmime.ParseMediaType(h.Get("Content-Disposition"))
if err == nil {
att.Disposition = proton.Disposition(disp)
if filename, ok := dispParams["filename"]; ok {
@ -585,7 +591,7 @@ func parseAttachment(h message.Header, body []byte) (Attachment, error) {
// (This is necessary because some clients don't set Content-Disposition at all,
// so we need to rely on other information to deduce if it's inline or attachment.)
if h.Has("Content-Disposition") {
disp, _, err := h.ContentDisposition()
disp, _, err := pmmime.ParseMediaType(h.Get("Content-Disposition"))
if err != nil {
return Attachment{}, err
}
@ -635,3 +641,168 @@ func forEachDecodedHeaderField(h message.Header, fn func(string, string) error)
return nil
}
func patchInlineImages(p *parser.Parser) error {
// This code will only attempt to patch the root level children. I tested with different email clients and as soon
// as you reply/forward a message the entire content gets converted into HTML (Apple Mail/Thunderbird/Evolution).
// If you are forcing text formatting (Evolution), the inline images of the original email are stripped.
// The only reason we need to apply this modification is that Apple Mail can send out text + inline image parts
// if the text does not exceed the 76 char column limit.
// Based on this, it's unlikely we will see any other variations.
root := p.Root()
children := root.Children()
if len(children) < 2 {
return nil
}
result := make([]inlinePatchJob, len(children))
var (
transformationNeeded bool
prevPart *parser.Part
prevContentType string
prevContentTypeMap map[string]string
)
for i := 0; i < len(children); i++ {
curPart := children[i]
contentType, contentTypeMap, err := curPart.ContentType()
if err != nil {
return fmt.Errorf("failed to get content type for for child %v:%w", i, err)
}
if rfc822.MIMEType(contentType) == rfc822.TextPlain {
result[i] = &inlinePatchBodyOnly{part: curPart, contentTypeMap: contentTypeMap}
} else if strings.HasPrefix(contentType, "image/") {
disposition, _, err := curPart.ContentDisposition()
if err != nil {
return fmt.Errorf("failted to get content disposition for child %v:%w", i, err)
}
if disposition == "inline" && !curPart.HasContentID() {
if rfc822.MIMEType(prevContentType) == rfc822.TextPlain {
result[i-1] = &inlinePatchBodyWithInlineImage{
textPart: prevPart,
imagePart: curPart,
textContentTypeMap: prevContentTypeMap,
}
} else {
result[i] = &inlinePatchInlineImageOnly{part: curPart, partIndex: i, root: root}
}
transformationNeeded = true
}
}
prevPart = curPart
prevContentType = contentType
prevContentTypeMap = contentTypeMap
}
if !transformationNeeded {
return nil
}
for _, t := range result {
if t != nil {
t.Patch()
}
}
return nil
}
type inlinePatchJob interface {
Patch()
}
// inlinePatchBodyOnly is meant to be used for standalone text parts that need to be converted to html once we applty
// one of the changes.
type inlinePatchBodyOnly struct {
part *parser.Part
contentTypeMap map[string]string
}
func (i *inlinePatchBodyOnly) Patch() {
newBody := []byte(`<html><body><p>`)
newBody = append(newBody, patchNewLineWithHTMLBreaks(i.part.Body)...)
newBody = append(newBody, []byte(`</p></body></html>`)...)
i.part.Body = newBody
i.part.Header.SetContentType("text/html", i.contentTypeMap)
}
// inlinePatchBodyWithInlineImage patches a previous text part so that it refers to that inline image.
type inlinePatchBodyWithInlineImage struct {
textPart *parser.Part
textContentTypeMap map[string]string
imagePart *parser.Part
}
// inlinePatchInlineImageOnly handle the case where the inline image is not proceeded by a text part. To avoid
// having to parse any possible previous part, we just inject a new part that references this image.
type inlinePatchInlineImageOnly struct {
part *parser.Part
partIndex int
root *parser.Part
}
func (i inlinePatchInlineImageOnly) Patch() {
contentID := uuid.NewString()
// Convert previous part to text/html && inject image.
newBody := []byte(fmt.Sprintf(`<html><body><img src="cid:%v"/></body></html>`, contentID))
i.part.Header.Set("content-id", contentID)
// create new text part
textPart := &parser.Part{
Header: message.Header{},
Body: newBody,
}
textPart.Header.SetContentType("text/html", map[string]string{"charset": "UTF-8"})
i.root.InsertChild(i.partIndex, textPart)
}
func (i *inlinePatchBodyWithInlineImage) Patch() {
contentID := uuid.NewString()
// Convert previous part to text/html && inject image.
newBody := []byte(`<html><body><p>`)
newBody = append(newBody, patchNewLineWithHTMLBreaks(i.textPart.Body)...)
newBody = append(newBody, []byte(`</p>`)...)
newBody = append(newBody, []byte(fmt.Sprintf(`<img src="cid:%v"/>`, contentID))...)
newBody = append(newBody, []byte(`</body></html>`)...)
i.textPart.Body = newBody
i.textPart.Header.SetContentType("text/html", i.textContentTypeMap)
// Add content id to curPart
i.imagePart.Header.Set("content-id", contentID)
}
func patchNewLineWithHTMLBreaks(input []byte) []byte {
dst := make([]byte, 0, len(input))
index := 0
for {
slice := input[index:]
newLineIndex := bytes.IndexByte(slice, '\n')
if newLineIndex == -1 {
dst = append(dst, input[index:]...)
return dst
}
injectIndex := newLineIndex
if newLineIndex > 0 && slice[newLineIndex-1] == '\r' {
injectIndex--
}
dst = append(dst, slice[0:injectIndex]...)
dst = append(dst, '<', 'b', 'r', '/', '>')
dst = append(dst, slice[injectIndex:newLineIndex+1]...)
index += newLineIndex + 1
}
}

View File

@ -32,6 +32,10 @@ func (h *handler) matchPart(p *Part) bool {
return h.matchType(p) || h.matchDisp(p)
}
func (h *handler) matchPartSkipAttachment(p *Part) bool {
return !p.isAttachment() && h.matchPart(p)
}
func (h *handler) matchType(p *Part) bool {
if h.typeRegExp == nil {
return false
@ -50,7 +54,7 @@ func (h *handler) matchDisp(p *Part) bool {
return false
}
disp, _, err := p.Header.ContentDisposition()
disp, _, err := p.ContentDisposition()
if err != nil {
disp = ""
}

View File

@ -27,6 +27,7 @@ import (
"github.com/PuerkitoBio/goquery"
"github.com/emersion/go-message"
"github.com/sirupsen/logrus"
"golang.org/x/exp/slices"
"golang.org/x/net/html"
"golang.org/x/net/html/charset"
"golang.org/x/text/encoding"
@ -52,6 +53,14 @@ func (p *Part) ContentType() (string, map[string]string, error) {
return t, params, err
}
func (p *Part) ContentDisposition() (string, map[string]string, error) {
return pmmime.ParseMediaType(p.Header.Get("Content-Disposition"))
}
func (p *Part) HasContentID() bool {
return len(p.Header.Get("content-id")) != 0
}
func (p *Part) Child(n int) (part *Part, err error) {
if len(p.children) < n {
return nil, errors.New("no such part")
@ -81,6 +90,14 @@ func (p *Part) AddChild(child *Part) {
}
}
func (p *Part) InsertChild(index int, child *Part) {
if p.isMultipartMixedOrRelated() {
p.children = slices.Insert(p.children, index, child)
} else {
p.AddChild(child)
}
}
func (p *Part) ConvertToUTF8() error {
logrus.Trace("Converting part to utf-8")
@ -183,6 +200,23 @@ func (p *Part) isMultipartMixed() bool {
return t == "multipart/mixed"
}
func (p *Part) isMultipartMixedOrRelated() bool {
t, _, err := p.ContentType()
if err != nil {
return false
}
return t == "multipart/mixed" || t == "multipart/related"
}
func (p *Part) isAttachment() bool {
disp, _, err := p.ContentDisposition()
if err != nil {
disp = ""
}
return disp == "attachment"
}
func getContentHeaders(header message.Header) message.Header {
var res message.Header

View File

@ -0,0 +1,86 @@
Content-Type: multipart/mixed; boundary="------------MQ01Z9UM8OaR9z39TvzDfdIq"
Subject: Fwd: Reply to this message, it has various attachments.
References: <something@protonmail.ch>
To: <[user:user2]@[domain]>
From: <[user:user]@[domain]>
In-Reply-To: <something@protonmail.ch>
X-Forwarded-Message-Id: <something@protonmail.ch>
This is a multi-part message in MIME format.
--------------MQ01Z9UM8OaR9z39TvzDfdIq
Content-Type: text/plain; charset=UTF-8; format=flowed
Content-Transfer-Encoding: 7bit
Forwarding a message with various attachments in it!
-------- Forwarded Message --------
Subject: Reply to this message, it has various attachments.
Date: Thu, 26 Oct 2023 10:41:55 +0000
From: Gjorgji Testing <gorgitesting@protonmail.com>
Reply-To: Gjorgji Testing <gorgitesting@protonmail.com>
To: Gjorgji Test v3 <gorgitesting3@protonmail.com>
For real!
*Gjorgji Testing
TesASID <https://www.youtube.com/watch?v=MifXUbrjYr8>
*
--------------MQ01Z9UM8OaR9z39TvzDfdIq
Content-Type: text/html; charset=UTF-8; name="index.html"
Content-Disposition: attachment; filename="index.html"
Content-Transfer-Encoding: base64
IDwhRE9DVFlQRSBodG1sPg0KPGh0bWw+DQo8aGVhZD4NCjx0aXRsZT5QYWdlIFRpdGxlPC90
aXRsZT4NCjwvaGVhZD4NCjxib2R5Pg0KDQo8aDE+TXkgRmlyc3QgSGVhZGluZzwvaDE+DQo8
cD5NeSBmaXJzdCBwYXJhZ3JhcGguPC9wPg0KDQo8L2JvZHk+DQo8L2h0bWw+IA==
--------------MQ01Z9UM8OaR9z39TvzDfdIq
Content-Type: text/xml; charset=UTF-8; name="testxml.xml"
Content-Disposition: attachment; filename="testxml.xml"
Content-Transfer-Encoding: base64
PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPCFET0NUWVBFIHN1aXRl
IFNZU1RFTSAiaHR0cDovL3Rlc3RuZy5vcmcvdGVzdG5nLTEuMC5kdGQiID4KCjxzdWl0ZSBu
YW1lPSJBZmZpbGlhdGUgTmV0d29ya3MiPgoKICAgIDx0ZXN0IG5hbWU9IkFmZmlsaWF0ZSBO
ZXR3b3JrcyIgZW5hYmxlZD0idHJ1ZSI+CiAgICAgICAgPGNsYXNzZXM+CiAgICAgICAgICAg
IDxjbGFzcyBuYW1lPSJjb20uY2xpY2tvdXQuYXBpdGVzdGluZy5hZmZOZXR3b3Jrcy5Bd2lu
VUtUZXN0Ii8+CiAgICAgICAgPC9jbGFzc2VzPgogICAgPC90ZXN0PgoKPC9zdWl0ZT4=
--------------MQ01Z9UM8OaR9z39TvzDfdIq
Content-Type: application/pdf; name="test.pdf"
Content-Disposition: attachment; filename="test.pdf"
Content-Transfer-Encoding: base64
JVBERi0xLjUKJeLjz9MKNyAwIG9iago8PAovVHlwZSAvRm9udERlc2NyaXB0b3IKL0ZvbnRO
MjM0NAolJUVPRgo=
--------------MQ01Z9UM8OaR9z39TvzDfdIq
Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;
name="test.xlsx"
Content-Disposition: attachment; filename="test.xlsx"
Content-Transfer-Encoding: base64
UEsDBBQABgAIAAAAIQBi7p1oXgEAAJAEAAATAAgCW0NvbnRlbnRfVHlwZXNdLnhtbCCiBAIo
UQIAABEAAAAAAAAAAAAAAAAARBcAAGRvY1Byb3BzL2NvcmUueG1sUEsBAi0AFAAGAAgAAAAh
AGFJCRCJAQAAEQMAABAAAAAAAAAAAAAAAAAAvBkAAGRvY1Byb3BzL2FwcC54bWxQSwUGAAAA
AAoACgCAAgAAexwAAAAA
--------------MQ01Z9UM8OaR9z39TvzDfdIq
Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document;
name="test.docx"
Content-Disposition: attachment; filename="test.docx"
Content-Transfer-Encoding: base64
UEsDBBQABgAIAAAAIQDfpNJsWgEAACAFAAATAAgCW0NvbnRlbnRfVHlwZXNdLnhtbCCiBAIo
cHAueG1sUEsBAi0AFAAGAAgAAAAhABA0tG9uAQAA4QIAABEAAAAAAAAAAAAAAAAA2xsAAGRv
Y1Byb3BzL2NvcmUueG1sUEsBAi0AFAAGAAgAAAAhAJ/mlBIqCwAAU3AAAA8AAAAAAAAAAAAA
AAAAgB4AAHdvcmQvc3R5bGVzLnhtbFBLBQYAAAAACwALAMECAADXKQAAAAA=
--------------MQ01Z9UM8OaR9z39TvzDfdIq
Content-Type: text/plain; charset=UTF-8; name="text file.txt"
Content-Disposition: attachment; filename="text file.txt"
Content-Transfer-Encoding: base64
dGV4dCBmaWxl
--------------MQ01Z9UM8OaR9z39TvzDfdIq--

View File

@ -33,9 +33,12 @@ func newWalker(root *Part) *Walker {
}
}
func (w *Walker) Walk() (err error) {
func (w *Walker) Walk() error {
return w.walkOverPart(w.root)
}
func (w *Walker) WalkSkipAttachment() error {
return w.walkOverPartSkipAttachment(w.root)
}
func (w *Walker) walkOverPart(p *Part) error {
if err := w.getHandlerFunc(p)(p); err != nil {
@ -51,6 +54,20 @@ func (w *Walker) walkOverPart(p *Part) error {
return nil
}
func (w *Walker) walkOverPartSkipAttachment(p *Part) error {
if err := w.getHandlerFuncSkipAttachment(p)(p); err != nil {
return err
}
for _, child := range p.children {
if err := w.walkOverPartSkipAttachment(child); err != nil {
return err
}
}
return nil
}
// RegisterDefaultHandler registers a handler that will be called on every part
// that doesn't match a registered content type/disposition handler.
func (w *Walker) RegisterDefaultHandler(fn HandlerFunc) *Walker {
@ -91,3 +108,13 @@ func (w *Walker) getHandlerFunc(p *Part) HandlerFunc {
return w.defaultHandler
}
func (w *Walker) getHandlerFuncSkipAttachment(p *Part) HandlerFunc {
for _, handler := range w.handlers {
if handler.matchPartSkipAttachment(p) {
return handler.fn
}
}
return w.defaultHandler
}

View File

@ -60,6 +60,27 @@ func TestWalkerTypeHandler(t *testing.T) {
}, html)
}
func TestWalkerTypeHandler_excludingAttachment(t *testing.T) {
p := newTestParser(t, "forwarding_html_attachment.eml")
html := [][]byte{}
plain := [][]byte{}
walker := p.NewWalker().
RegisterContentTypeHandler("text/html", func(p *Part) (err error) {
html = append(html, p.Body)
return
}).
RegisterContentTypeHandler("text/plain", func(p *Part) (err error) {
plain = append(plain, p.Body)
return
})
assert.NoError(t, walker.WalkSkipAttachment())
assert.Equal(t, 1, len(plain))
assert.Equal(t, 0, len(html))
}
func TestWalkerDispositionHandler(t *testing.T) {
p := newTestParser(t, "text_html_octet_attachment.eml")

View File

@ -19,6 +19,7 @@ package message
import (
"bytes"
"fmt"
"image/png"
"io"
"os"
@ -312,11 +313,13 @@ func TestParseTextPlainWithImageInline(t *testing.T) {
m, err := Parse(f)
require.NoError(t, err)
require.NotEmpty(t, m.Attachments[0].ContentID)
assert.Equal(t, `"Sender" <sender@pm.me>`, m.Sender.String())
assert.Equal(t, `"Receiver" <receiver@pm.me>`, m.ToList[0].String())
assert.Equal(t, "body", string(m.RichBody))
assert.Equal(t, "body", string(m.PlainBody))
assert.Equal(t, fmt.Sprintf(`<html><body><p>body</p><img src="cid:%v"/></body></html>`, m.Attachments[0].ContentID), string(m.RichBody))
// The inline image is an 8x8 mic-dropping gopher.
require.Len(t, m.Attachments, 1)
@ -326,6 +329,69 @@ func TestParseTextPlainWithImageInline(t *testing.T) {
assert.Equal(t, 8, img.Height)
}
func TestParseTextPlainWithImageInlineWithMoreTextParts(t *testing.T) {
// Inline image test with text - image - text, ensure all parts are convert to html
f := getFileReader("text_plain_image_inline2.eml")
m, err := Parse(f)
require.NoError(t, err)
require.NotEmpty(t, m.Attachments[0].ContentID)
assert.Equal(t, "bodybody2", string(m.PlainBody))
assert.Equal(t, fmt.Sprintf("<html><body><p>body</p><img src=\"cid:%v\"/></body></html><html><body><p>body2<br/>\n</p></body></html>", m.Attachments[0].ContentID), string(m.RichBody))
// The inline image is an 8x8 mic-dropping gopher.
require.Len(t, m.Attachments, 1)
img, err := png.DecodeConfig(bytes.NewReader(m.Attachments[0].Data))
require.NoError(t, err)
assert.Equal(t, 8, img.Width)
assert.Equal(t, 8, img.Height)
}
func TestParseTextPlainWithImageInlineAfterOtherAttachment(t *testing.T) {
// Inline image test with text - image - text, ensure all parts are convert to html
f := getFileReader("text_plain_image_inline2.eml")
m, err := Parse(f)
require.NoError(t, err)
require.NotEmpty(t, m.Attachments[0].ContentID)
assert.Equal(t, "bodybody2", string(m.PlainBody))
assert.Equal(t, fmt.Sprintf("<html><body><p>body</p><img src=\"cid:%v\"/></body></html><html><body><p>body2<br/>\n</p></body></html>", m.Attachments[0].ContentID), string(m.RichBody))
// The inline image is an 8x8 mic-dropping gopher.
require.Len(t, m.Attachments, 1)
img, err := png.DecodeConfig(bytes.NewReader(m.Attachments[0].Data))
require.NoError(t, err)
assert.Equal(t, 8, img.Width)
assert.Equal(t, 8, img.Height)
}
func TestParseTextPlainWithImageBetweenAttachments(t *testing.T) {
// Inline image test with text - pdf - image - text. A new part must be created to be injected.
f := getFileReader("text_plain_image_inline_between_attachment.eml")
m, err := Parse(f)
require.NoError(t, err)
require.Empty(t, m.Attachments[0].ContentID)
require.NotEmpty(t, m.Attachments[1].ContentID)
assert.Equal(t, "bodybody2", string(m.PlainBody))
assert.Equal(t, fmt.Sprintf("<html><body><p>body</p></body></html><html><body><img src=\"cid:%v\"/></body></html><html><body><p>body2<br/>\n</p></body></html>", m.Attachments[1].ContentID), string(m.RichBody))
}
func TestParseTextPlainWithImageFirst(t *testing.T) {
// Inline image test with text - pdf - image - text. A new part must be created to be injected.
f := getFileReader("text_plain_image_inline_attachment_first.eml")
m, err := Parse(f)
require.NoError(t, err)
require.NotEmpty(t, m.Attachments[0].ContentID)
assert.Equal(t, "body", string(m.PlainBody))
assert.Equal(t, fmt.Sprintf("<html><body><img src=\"cid:%v\"/></body></html><html><body><p>body</p></body></html>", m.Attachments[0].ContentID), string(m.RichBody))
}
func TestParseTextPlainWithDuplicateCharset(t *testing.T) {
f := getFileReader("text_plain_duplicate_charset.eml")
@ -428,11 +494,12 @@ func TestParseTextHTMLWithImageInline(t *testing.T) {
assert.Equal(t, `"Sender" <sender@pm.me>`, m.Sender.String())
assert.Equal(t, `"Receiver" <receiver@pm.me>`, m.ToList[0].String())
assert.Equal(t, "<html><head></head><body>This is body of <b>HTML mail</b> with attachment</body></html>", string(m.RichBody))
require.Len(t, m.Attachments, 1)
assert.Equal(t, fmt.Sprintf(`<html><head></head><body>This is body of <b>HTML mail</b> with attachment</body></html><html><body><img src="cid:%v"/></body></html>`, m.Attachments[0].ContentID), string(m.RichBody))
assert.Equal(t, "This is body of *HTML mail* with attachment", string(m.PlainBody))
// The inline image is an 8x8 mic-dropping gopher.
require.Len(t, m.Attachments, 1)
img, err := png.DecodeConfig(bytes.NewReader(m.Attachments[0].Data))
require.NoError(t, err)
assert.Equal(t, 8, img.Width)
@ -539,6 +606,18 @@ func TestParseMultipartAlternativeLatin1(t *testing.T) {
assert.Equal(t, "*aoeuaoeu*\n\n", string(m.PlainBody))
}
func TestParseMultipartAttachmentEncodedButUnquoted(t *testing.T) {
f := getFileReader("multipart_attachment_encoded_no_quote.eml")
p, err := parser.New(f)
require.NoError(t, err)
m, err := ParseWithParser(p, false)
require.NoError(t, err)
assert.Equal(t, `"Bridge Test" <bridgetest@pm.test>`, m.Sender.String())
assert.Equal(t, `"Internal Bridge" <bridgetest@protonmail.com>`, m.ToList[0].String())
}
func TestParseWithTrailingEndOfMailIndicator(t *testing.T) {
f := getFileReader("text_html_trailing_end_of_mail.eml")
@ -707,6 +786,23 @@ func TestParseTextPlainWithDocxAttachmentCyrillic(t *testing.T) {
assert.Equal(t, "АБВГДЃЕЖЗЅИЈКЛЉМНЊОПРСТЌУФХЧЏЗШ.docx", m.Attachments[0].Name)
}
func TestPatchNewLineWithHtmlBreaks(t *testing.T) {
{
input := []byte("\nfoo\nbar\n\n\nzz\nddd")
expected := []byte("<br/>\nfoo<br/>\nbar<br/>\n<br/>\n<br/>\nzz<br/>\nddd")
result := patchNewLineWithHTMLBreaks(input)
require.Equal(t, expected, result)
}
{
input := []byte("\r\nfoo\r\nbar\r\n\r\n\r\nzz\r\nddd")
expected := []byte("<br/>\r\nfoo<br/>\r\nbar<br/>\r\n<br/>\r\n<br/>\r\nzz<br/>\r\nddd")
result := patchNewLineWithHTMLBreaks(input)
require.Equal(t, expected, result)
}
}
func getFileReader(filename string) io.Reader {
f, err := os.Open(filepath.Join("testdata", filename))
if err != nil {

View File

@ -0,0 +1,27 @@
From: Bridge Test <bridgetest@pm.test>
Date: 01 Jan 1980 00:00:00 +0000
To: Internal Bridge <bridgetest@protonmail.com>
Subject: Message with attachment name
Content-type: multipart/mixed; boundary="boundary"
Received: by 2002:0:0:0:0:0:0:0 with SMTP id 0123456789abcdef; Wed, 30 Dec 2020 01:23:45 0000
This is a multi-part message in MIME format.
--boundary
Content-Type: text/plain
Hello
--boundary
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: 7bit
<h1> HELLO </h1>
--boundary
Content-Type: application/pdf; name==?US-ASCII?Q?filename?=
Content-Disposition: attachment; filename==?US-ASCII?Q?filename?=
somebytes
--boundary--

View File

@ -0,0 +1,39 @@
From: Sender <sender@pm.me>
To: Receiver <receiver@pm.me>
Content-Type: multipart/related; boundary=longrandomstring
--longrandomstring
body
--longrandomstring
Content-Type: image/png
Content-Disposition: inline
Content-Transfer-Encoding: base64
iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAABGdBTUEAALGPC/xhBQAAACBjSFJ
NAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAhGVYSWZNTQAqAAAACAAFAR
IAAwAAAAEAAQAAARoABQAAAAEAAABKARsABQAAAAEAAABSASgAAwAAAAEAAgAAh2kABAAAAAEAA
ABaAAAAAAAAASwAAAABAAABLAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAACKADAAQAAAAB
AAAACAAAAAAAXWZ6AAAACXBIWXMAAC4jAAAuIwF4pT92AAACZmlUWHRYTUw6Y29tLmFkb2JlLnh
tcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIE
NvcmUgNS40LjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5O
TkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91
dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4
wLyIKICAgICAgICAgICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC
8iPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgI
CAgICA8dGlmZjpSZXNvbHV0aW9uVW5pdD4yPC90aWZmOlJlc29sdXRpb25Vbml0PgogICAgICAg
ICA8ZXhpZjpDb2xvclNwYWNlPjE8L2V4aWY6Q29sb3JTcGFjZT4KICAgICAgICAgPGV4aWY6UGl
4ZWxYRGltZW5zaW9uPjE2PC9leGlmOlBpeGVsWERpbWVuc2lvbj4KICAgICAgICAgPGV4aWY6UG
l4ZWxZRGltZW5zaW9uPjE2PC9leGlmOlBpeGVsWURpbWVuc2lvbj4KICAgICAgPC9yZGY6RGVzY
3JpcHRpb24+CiAgIDwvcmRmOlJERj4KPC94OnhtcG1ldGE+CgZBD4sAAAEISURBVBgZY2CAAO5F
x07Zz96xZ0Pn4lXqIKGGhgYmsFTHvAWdW6/dvnb89Yf/B5+9/r/y9IXzbVPahCH6/jMysfAJygo
JC2r++/T619Mb139J8HIb8Gs5hYMUzJ+/gJ1Jmo9H6c+L5wz3bt5iEeLmYOHn42fQ4vyacqGNQS
0xMfEHc7Cvl6CYho4rh5jUPyYefqafLKyMbH9+/d28/dFfdWtfDaZvTy7Zvv72nYGZkeEvw98/f
5j//2P4yCvxq/nU7zVs//8yM2gzMMitOnnu5cUff/8ff/v5/5Xf///vuHBhJcSRDAws9aEMr38c
W7XjNgvzexZ2rn9vbjx/IXl/M9iLM2fOZAUAKCZv7dU+UgAAAAAASUVORK5CYII=
--longrandomstring
body2
--longrandomstring--

View File

@ -0,0 +1,35 @@
From: Sender <sender@pm.me>
To: Receiver <receiver@pm.me>
Content-Type: multipart/related; boundary=longrandomstring
--longrandomstring
Content-Type: image/png
Content-Disposition: inline
Content-Transfer-Encoding: base64
iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAABGdBTUEAALGPC/xhBQAAACBjSFJ
NAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAhGVYSWZNTQAqAAAACAAFAR
IAAwAAAAEAAQAAARoABQAAAAEAAABKARsABQAAAAEAAABSASgAAwAAAAEAAgAAh2kABAAAAAEAA
ABaAAAAAAAAASwAAAABAAABLAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAACKADAAQAAAAB
AAAACAAAAAAAXWZ6AAAACXBIWXMAAC4jAAAuIwF4pT92AAACZmlUWHRYTUw6Y29tLmFkb2JlLnh
tcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIE
NvcmUgNS40LjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5O
TkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91
dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4
wLyIKICAgICAgICAgICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC
8iPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgI
CAgICA8dGlmZjpSZXNvbHV0aW9uVW5pdD4yPC90aWZmOlJlc29sdXRpb25Vbml0PgogICAgICAg
ICA8ZXhpZjpDb2xvclNwYWNlPjE8L2V4aWY6Q29sb3JTcGFjZT4KICAgICAgICAgPGV4aWY6UGl
4ZWxYRGltZW5zaW9uPjE2PC9leGlmOlBpeGVsWERpbWVuc2lvbj4KICAgICAgICAgPGV4aWY6UG
l4ZWxZRGltZW5zaW9uPjE2PC9leGlmOlBpeGVsWURpbWVuc2lvbj4KICAgICAgPC9yZGY6RGVzY
3JpcHRpb24+CiAgIDwvcmRmOlJERj4KPC94OnhtcG1ldGE+CgZBD4sAAAEISURBVBgZY2CAAO5F
x07Zz96xZ0Pn4lXqIKGGhgYmsFTHvAWdW6/dvnb89Yf/B5+9/r/y9IXzbVPahCH6/jMysfAJygo
JC2r++/T619Mb139J8HIb8Gs5hYMUzJ+/gJ1Jmo9H6c+L5wz3bt5iEeLmYOHn42fQ4vyacqGNQS
0xMfEHc7Cvl6CYho4rh5jUPyYefqafLKyMbH9+/d28/dFfdWtfDaZvTy7Zvv72nYGZkeEvw98/f
5j//2P4yCvxq/nU7zVs//8yM2gzMMitOnnu5cUff/8ff/v5/5Xf///vuHBhJcSRDAws9aEMr38c
W7XjNgvzexZ2rn9vbjx/IXl/M9iLM2fOZAUAKCZv7dU+UgAAAAAASUVORK5CYII=
--longrandomstring
body
--longrandomstring--

View File

@ -0,0 +1,46 @@
From: Sender <sender@pm.me>
To: Receiver <receiver@pm.me>
Content-Type: multipart/related; boundary=longrandomstring
--longrandomstring
body
--longrandomstring
Content-Type: application/pdf
Content-Disposition: inline
Content-Transfer-Encoding: base64
aGVsbG8gd29ybGQgcGRm
--longrandomstring
Content-Type: image/png
Content-Disposition: inline
Content-Transfer-Encoding: base64
iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAABGdBTUEAALGPC/xhBQAAACBjSFJ
NAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAhGVYSWZNTQAqAAAACAAFAR
IAAwAAAAEAAQAAARoABQAAAAEAAABKARsABQAAAAEAAABSASgAAwAAAAEAAgAAh2kABAAAAAEAA
ABaAAAAAAAAASwAAAABAAABLAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAACKADAAQAAAAB
AAAACAAAAAAAXWZ6AAAACXBIWXMAAC4jAAAuIwF4pT92AAACZmlUWHRYTUw6Y29tLmFkb2JlLnh
tcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIE
NvcmUgNS40LjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5O
TkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91
dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4
wLyIKICAgICAgICAgICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC
8iPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgI
CAgICA8dGlmZjpSZXNvbHV0aW9uVW5pdD4yPC90aWZmOlJlc29sdXRpb25Vbml0PgogICAgICAg
ICA8ZXhpZjpDb2xvclNwYWNlPjE8L2V4aWY6Q29sb3JTcGFjZT4KICAgICAgICAgPGV4aWY6UGl
4ZWxYRGltZW5zaW9uPjE2PC9leGlmOlBpeGVsWERpbWVuc2lvbj4KICAgICAgICAgPGV4aWY6UG
l4ZWxZRGltZW5zaW9uPjE2PC9leGlmOlBpeGVsWURpbWVuc2lvbj4KICAgICAgPC9yZGY6RGVzY
3JpcHRpb24+CiAgIDwvcmRmOlJERj4KPC94OnhtcG1ldGE+CgZBD4sAAAEISURBVBgZY2CAAO5F
x07Zz96xZ0Pn4lXqIKGGhgYmsFTHvAWdW6/dvnb89Yf/B5+9/r/y9IXzbVPahCH6/jMysfAJygo
JC2r++/T619Mb139J8HIb8Gs5hYMUzJ+/gJ1Jmo9H6c+L5wz3bt5iEeLmYOHn42fQ4vyacqGNQS
0xMfEHc7Cvl6CYho4rh5jUPyYefqafLKyMbH9+/d28/dFfdWtfDaZvTy7Zvv72nYGZkeEvw98/f
5j//2P4yCvxq/nU7zVs//8yM2gzMMitOnnu5cUff/8ff/v5/5Xf///vuHBhJcSRDAws9aEMr38c
W7XjNgvzexZ2rn9vbjx/IXl/M9iLM2fOZAUAKCZv7dU+UgAAAAAASUVORK5CYII=
--longrandomstring
body2
--longrandomstring--

View File

@ -256,6 +256,10 @@ 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) (mediatype string, params map[string]string, err error) {
v, _ = changeEncodingAndKeepLastParamDefinition(v)
decoded, err := DecodeHeader(v)
if err != nil {
return "", nil, err
}
v, _ = changeEncodingAndKeepLastParamDefinition(decoded)
return mime.ParseMediaType(v)
}

View File

@ -155,34 +155,29 @@ func (s *scenario) theUserSetSMTPModeToSSL() error {
}
type testBugReport struct {
OSType string `json:"OS"`
OSVersion string `json:"OSVersion"`
Title string `json:"Title"`
Description string `json:"Description"`
Username string `json:"Username"`
Email string `json:"Email"`
Client string `json:"Client"`
Attachment bool `json:"Attachment"`
bridge *bridge.Bridge
request bridge.ReportBugReq
bridge *bridge.Bridge
}
func newTestBugReport(bridge *bridge.Bridge) *testBugReport {
return &testBugReport{
func newTestBugReport(br *bridge.Bridge) *testBugReport {
request := bridge.ReportBugReq{
OSType: "osType",
OSVersion: "osVersion",
Title: "title",
Description: "description",
Username: "username",
Email: "email",
Client: "client",
Attachment: false,
bridge: bridge,
EmailClient: "client",
IncludeLogs: false,
}
return &testBugReport{
request: request,
bridge: br,
}
}
func (r *testBugReport) report() error {
return r.bridge.ReportBug(context.Background(), r.OSType, r.OSVersion, r.Title, r.Description, r.Username, r.Email, r.Client, r.Attachment)
return r.bridge.ReportBug(context.Background(), &r.request)
}
func (s *scenario) theUserReportsABug() error {
@ -194,25 +189,25 @@ func (s *scenario) theUserReportsABugWithSingleHeaderChange(key, value string) e
switch key {
case "osType":
bugReport.OSType = value
bugReport.request.OSType = value
case "osVersion":
bugReport.OSVersion = value
bugReport.request.OSVersion = value
case "Title":
bugReport.Title = value
bugReport.request.Title = value
case "Description":
bugReport.Description = value
bugReport.request.Description = value
case "Username":
bugReport.Username = value
bugReport.request.Username = value
case "Email":
bugReport.Email = value
bugReport.request.Email = value
case "Client":
bugReport.Client = value
bugReport.request.EmailClient = value
case "Attachment":
att, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("failed to parse bug report attachment preferences: %w", err)
}
bugReport.Attachment = att
bugReport.request.IncludeLogs = att
default:
return fmt.Errorf("Wrong header (\"%s\") is being checked", key)
}
@ -222,10 +217,9 @@ func (s *scenario) theUserReportsABugWithSingleHeaderChange(key, value string) e
func (s *scenario) theUserReportsABugWithDetails(value *godog.DocString) error {
bugReport := newTestBugReport(s.t.bridge)
if err := json.Unmarshal([]byte(value.Content), &bugReport); err != nil {
if err := json.Unmarshal([]byte(value.Content), &bugReport.request); err != nil {
return fmt.Errorf("cannot parse bug report details: %w", err)
}
return bugReport.report()
}
@ -299,17 +293,16 @@ func (s *scenario) bridgeSendsSyncStartedAndFinishedEventsForUser(username strin
break
}
for {
finishEvent, ok := awaitType(s.t.events, events.SyncFinished{}, 30*time.Second)
if !ok {
return errors.New("expected sync finished event, got none")
}
finishEvent, ok := awaitType(s.t.events, events.SyncFinished{}, 30*time.Second)
if !ok {
return errors.New("expected sync finished event, got none")
if wantUserID := s.t.getUserByName(username).getUserID(); finishEvent.UserID == wantUserID {
return nil
}
}
if wantUserID := s.t.getUserByName(username).getUserID(); finishEvent.UserID != wantUserID {
return fmt.Errorf("expected sync finished event for user %s, got %s", wantUserID, finishEvent.UserID)
}
return nil
}
func (s *scenario) bridgeSendsAnUpdateNotAvailableEvent() error {

View File

@ -362,8 +362,8 @@ func createContact(ctx context.Context, c *proton.Client, contact, name string,
if err != nil {
return err
}
if res[0].Response.APIError.Code != proton.SuccessCode {
return errors.New("APIError " + res[0].Response.APIError.Message + " while creating contact")
if res[0].Response.Code != proton.SuccessCode {
return errors.New("APIError " + res[0].Response.Message + " while creating contact")
}
if settings != nil {

View File

@ -67,57 +67,46 @@ Feature: IMAP import messages
"""
Scenario: Import message with attachment name encoded by RFC 2047 without quoting
When IMAP client "1" appends the following message to "INBOX":
"""
From: Bridge Test <bridgetest@pm.test>
Date: 01 Jan 1980 00:00:00 +0000
To: Internal Bridge <bridgetest@protonmail.com>
Subject: Message with attachment name encoded by RFC 2047 without quoting
Content-type: multipart/mixed; boundary="boundary"
Received: by 2002:0:0:0:0:0:0:0 with SMTP id 0123456789abcdef; Wed, 30 Dec 2020 01:23:45 0000
--boundary
Content-Type: text/plain
Hello
--boundary
Content-Type: application/pdf; name==?US-ASCII?Q?filename?=
Content-Disposition: attachment; filename==?US-ASCII?Q?filename?=
somebytes
--boundary--
"""
Scenario Outline: Import multipart message with attachment <message>
When IMAP client "1" appends <message> to "INBOX"
Then it succeeds
# And IMAP client "1" eventually sees the following message in "INBOX" with this structure:
# """
# {
# "from": "Bridge Test <bridgetest@pm.test>",
# "date": "01 Jan 80 00:00 +0000",
# "to": "Internal Bridge <bridgetest@protonmail.com>",
# "subject": "Message with attachment name encoded by RFC 2047 without quoting",
# "body-contains": "Hello",
# "content": {
# "content-type": "multipart/mixed; boundary=\"boundary\"",
# "sections":[
# {
# "content-type": "text/plain",
# "body-is": "Hello"
# },
# {
# "content-type": "application/pdf",
# "content-type-name": "=?US-ASCII?Q?filename?=",
# "content-disposition": "attachment",
# "content-disposition-filename": "=?US-ASCII?Q?filename?=",
# "body-is": "somebytes"
# }
# ]
# }
# }
# """
And IMAP client "1" eventually sees the following message in "INBOX" with this structure:
"""
{
"from": "Bridge Test <bridgetest@pm.test>",
"date": "01 Jan 80 00:00 +0000",
"to": "Internal Bridge <bridgetest@protonmail.com>",
"subject": "Message with attachment name",
"body-contains": "Hello",
"content": {
"content-type": "multipart/mixed",
"sections":[
{
"content-type": "text/plain",
"body-is": "Hello"
},
{
"content-type": "text/html",
"content-type-charset": "utf-8",
"transfer-encoding": "7bit",
"body-contains": "HELLO"
},
{
"content-type": "application/pdf",
"content-type-name": "filename",
"content-disposition": "attachment",
"content-disposition-filename": "filename",
"body-is": "somebytes"
}
]
}
}
"""
Examples:
| message |
| "multipart/mixed_with_attachment_encoded.eml" |
| "multipart/mixed_with_attachment_encoded_no_quote.eml" |
| "multipart/mixed_with_attachment_no_quote.eml" |
# The message is imported as UTF-8 and the content type is determined at build time.
@ -322,8 +311,6 @@ Feature: IMAP import messages
Content-Type: multipart/mixed; boundary="boundary"
Received: by 2002:0:0:0:0:0:0:0 with SMTP id 0123456789abcdef; Wed, 30 Dec 2020 01:23:45 0000
--boundary
This is a multi-part message in MIME format.
--boundary
@ -366,9 +353,6 @@ Feature: IMAP import messages
"content": {
"content-type": "multipart/mixed",
"sections":[
{
"body-is": "This is a multi-part message in MIME format."
},
{
"content-type": "text/plain",
"content-type-charset": "utf-8",
@ -393,3 +377,84 @@ Feature: IMAP import messages
}
}
"""
@regression
Scenario: Import message with remote content
When IMAP client "1" appends the following message to "Inbox":
"""
Date: 01 Jan 1980 00:00:00 +0000
To: Bridge Test <bridge@test.com>
From: Bridge Second Test <bridge_second@test.com>
Subject: MESSAGE WITH REMOTE CONTENT
Content-Type: multipart/alternative;
boundary="------------vUMV7TiM65KWBg30p6OgD3Vp"
This is a multi-part message in MIME format.
--------------vUMV7TiM65KWBg30p6OgD3Vp
Content-Type: text/plain; charset=utf-8; format=flowed
Content-Transfer-Encoding: 7bit
Remote content
Bridge
Remote content
--------------vUMV7TiM65KWBg30p6OgD3Vp
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: 7bit
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
</head>
<body>
<p><tt>Remote content</tt></p>
<p><tt><br>
</tt></p>
<p><img
src="https://bridgeteam.protontech.ch/bridgeteam/tmp/bridge.jpg"
alt="Bridge" width="180" height="180"></p>
<p><br>
</p>
<p><tt>Remote content</tt><br>
</p>
<br>
</body>
</html>
--------------vUMV7TiM65KWBg30p6OgD3Vp--
"""
Then it succeeds
And IMAP client "1" eventually sees the following message in "Inbox" with this structure:
"""
{
"date": "01 Jan 80 00:00 +0000",
"to": "Bridge Test <bridge@test.com>",
"from": "Bridge Second Test <bridge_second@test.com>",
"subject": "MESSAGE WITH REMOTE CONTENT",
"content": {
"content-type": "multipart/alternative",
"sections":[
{
"content-type": "text/plain",
"content-type-charset": "utf-8",
"transfer-encoding": "7bit",
"body-is": "Remote content\n\n\nBridge\n\n\nRemote content"
},
{
"content-type": "text/html",
"content-type-charset": "utf-8",
"transfer-encoding": "7bit",
"body-is": "<!DOCTYPE html>\n<html>\n <head>\n\n <meta http-equiv=\"content-type\" content=\"text/html; charset=utf-8\">\n </head>\n <body>\n <p><tt>Remote content</tt></p>\n <p><tt><br>\n </tt></p>\n <p><img\n src=\"https://bridgeteam.protontech.ch/bridgeteam/tmp/bridge.jpg\"\n alt=\"Bridge\" width=\"180\" height=\"180\"></p>\n <p><br>\n </p>\n <p><tt>Remote content</tt><br>\n </p>\n <br>\n </body>\n</html>"
}
]
}
}
"""

View File

@ -140,4 +140,80 @@ Feature: SMTP sending with attachment
"Disposition": "attachment"
}
}
"""
"""
And IMAP client "1" eventually sees the following message in "Sent" with this structure:
"""
{
"subject": "Test with cyrillic attachment",
"body-contains": "Shake that body",
"content": {
"content-type": "multipart/mixed",
"sections":[
{
"content-type": "text/plain",
"body-is": "Shake that body"
},
{
"content-type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"content-type-name": "АБВГДЃЕЖЗЅИЈКЛЉМНЊОПРСТЌУФХЧЏЗШ.docx",
"content-disposition": "attachment",
"content-disposition-filename": "АБВГДЃЕЖЗЅИЈКЛЉМНЊОПРСТЌУФХЧЏЗШ.docx"
}
]
}
}
"""
Scenario Outline: Send message with attachment <UseCase>
When SMTP client "1" sends the following message from "[user:user1]@[domain]" to "[user:user2]@[domain]":
"""
Subject: Message with attachment name
Content-type: multipart/mixed; boundary="boundary"
Received: by 2002:0:0:0:0:0:0:0 with SMTP id 0123456789abcdef; Wed, 30 Dec 2020 01:23:45 0000
This is a multi-part message in MIME format.
--boundary
Content-Type: text/plain
Hello
--boundary
Content-Type: application/pdf; name=<filename>
Content-Disposition: attachment; filename=<filename>
somebytes
--boundary--
"""
Then it succeeds
And IMAP client "1" eventually sees the following message in "Sent" with this structure:
"""
{
"subject": "Message with attachment name",
"body-contains": "Hello",
"content": {
"content-type": "multipart/mixed",
"sections":[
{
"content-type": "text/plain",
"body-is": "Hello"
},
{
"content-type": "application/pdf",
"content-type-name": "filename",
"content-disposition": "attachment",
"content-disposition-filename": "filename",
"transfer-encoding":"base64",
"body-is": "c29tZWJ5dGVzDQo="
}
]
}
}
"""
Examples:
| UseCase | filename |
| encoded quoted | "=?US-ASCII?Q?filename?=" |
| encoded unquoted | =?US-ASCII?Q?filename?= |
| non quoted | filename |

View File

@ -1,10 +1,11 @@
Feature: SMTP sending of plain messages
Background:
Given there exists an account with username "[user:user]" and password "password"
And there exists an account with username "[user:to]" and password "password"
And there exists an account with username "[user:user2]" and password "password"
Then it succeeds
When bridge starts
And the user logs in with username "[user:user]" and password "password"
And user "[user:user]" finishes syncing
And user "[user:user]" connects and authenticates SMTP client "1"
Then it succeeds
@ -123,12 +124,45 @@ Feature: SMTP sending of plain messages
}
}
"""
And IMAP client "1" eventually sees the following message in "Sent" with this structure:
"""
{
"date": "01 Jan 01 00:00 +0000",
"to": "External Bridge <pm.bridge.qa@gmail.com>",
"from": "Bridge Test <[user:user]@[domain]>",
"subject": "Html Inline External",
"content": {
"content-type": "multipart/mixed",
"sections":[
{
"content-type": "multipart/related",
"sections":[
{
"content-type": "text/html",
"content-type-charset": "utf-8",
"transfer-encoding": "quoted-printable",
"body-is": "<html><head>\r\n<meta http-equiv=3D\"content-type\" content=3D\"text/html; charset=3DUTF-8\"/>\r\n</head>\r\n<body text=3D\"#000000\" bgcolor=3D\"#FFFFFF\">\r\n<p><br/>\r\n</p>\r\n<p>Behold! An inline <img moz-do-not-send=3D\"false\" src=3D\"cid:part1.D96BFA=\r\nE9.E2E1CAE3@protonmail.com\" alt=3D\"\" width=3D\"24\" height=3D\"24\"/><br/>\r\n</p>\r\n\r\n\r\n</body></html>"
},
{
"content-type": "image/gif",
"content-type-name": "email-action-left.gif",
"content-disposition": "inline",
"content-disposition-filename": "email-action-left.gif",
"transfer-encoding": "base64",
"body-is": "R0lGODlhGAAYANUAACcsKOHs4kppTH6tgYWxiIq0jTVENpG5lDI/M7bRuEaJSkqOTk2RUU+PU16l\r\nYl+lY2iva262cXS6d3rDfYLNhWeeamKTZGSVZkNbRGqhbOPt4////+7u7qioqFZWVlNTUyIiIgAA\r\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\r\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwAAAAAGAAYAAAG/8CNcLjRJAqVRqNS\r\nSGiI0GFgoKhar4NAdHioMhyRCYUyiTgY1cOWUH1ILgIDAGAQXCSPKgHaXUAyGCCCg4IYGRALCmpC\r\nAVUQFgiEkiAIFhBVWhtUDxmRk5IIGXkDRQoMEoGfHpIYEmhGCg4XnyAdHB+SFw4KRwoRArQdG7eE\r\nAhEKSAoTBoIdzs/Cw7iCBhMKSQoUAIJbQ8QgABQKStnbIN1C3+HjFcrMtdDO6dMg1dcFvsCfwt+C\r\nxsgJYs3a10+QLl4aTKGitYpQq1eaFHDyREtQqFGMHEGqSMkSJi4K/ACiZQiRIihsJL6JM6fOnTwK\r\n9kTpYgqMGDJm0JzsNuWKTw0FWdANMYJECRMnW4IAADs="
}
]
}
]
}
}
"""
Scenario: HTML message with alternative inline to internal account
When SMTP client "1" sends the following message from "[user:user]@[domain]" to "[user:to]@[domain]":
When SMTP client "1" sends the following message from "[user:user]@[domain]" to "[user:user2]@[domain]":
"""
From: Bridge Test <[user:user]@[domain]>
To: Internal Bridge <[user:to]@[domain]>
To: Internal Bridge <[user:user2]@[domain]>
Subject: Html Inline Alternative Internal
Content-Disposition: inline
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Thunderbird/60.5.0
@ -192,7 +226,7 @@ Feature: SMTP sending of plain messages
When user "[user:user]" connects and authenticates IMAP client "1"
Then IMAP client "1" eventually sees the following messages in "Sent":
| from | to | subject |
| [user:user]@[domain] | [user:to]@[domain] | Html Inline Alternative Internal |
| [user:user]@[domain] | [user:user2]@[domain] | Html Inline Alternative Internal |
And the body in the "POST" request to "/mail/v4/messages" is:
"""
{
@ -203,7 +237,7 @@ Feature: SMTP sending of plain messages
},
"ToList": [
{
"Address": "[user:to]@[domain]",
"Address": "[user:user2]@[domain]",
"Name": "Internal Bridge"
}
],
@ -344,7 +378,7 @@ Feature: SMTP sending of plain messages
}
"""
Scenario: HTML message with Foreign/Nonascii chars in Subject and Body
Scenario: HTML message with Foreign/Nonascii chars in Subject and Body to external
When there exists an account with username "bridgetest" and password "password"
And the user logs in with username "bridgetest" and password "password"
And user "bridgetest" connects and authenticates SMTP client "1"
@ -374,3 +408,242 @@ Feature: SMTP sending of plain messages
}
}
"""
# It is expected for the structure check to look a bit different. More info on GODT-3011
@regression
Scenario: HTML message with remote content in Body
When SMTP client "1" sends the following message from "[user:user]@[domain]" to "[user:to]@[domain]":
"""
Date: 01 Jan 1980 00:00:00 +0000
To: Internal Bridge Test <[user:to]@[domain]>
From: Bridge Test <[user:user]@[domain]>
Subject: MESSAGE WITH REMOTE CONTENT SENT
Content-Type: multipart/alternative;
boundary="------------vUMV7TiM65KWBg30p6OgD3Vp"
This is a multi-part message in MIME format.
--------------vUMV7TiM65KWBg30p6OgD3Vp
Content-Type: text/plain; charset=utf-8; format=flowed
Content-Transfer-Encoding: 7bit
Remote content
Bridge
Remote content
--------------vUMV7TiM65KWBg30p6OgD3Vp
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: 7bit
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
</head>
<body>
<p><tt>Remote content</tt></p>
<p><tt><br>
</tt></p>
<p><img
src="https://bridgeteam.protontech.ch/bridgeteam/tmp/bridge.jpg"
alt="Bridge" width="180" height="180"></p>
<p><br>
</p>
<p><tt>Remote content</tt><br>
</p>
<br>
</body>
</html>
--------------vUMV7TiM65KWBg30p6OgD3Vp--
"""
Then it succeeds
When user "[user:user]" connects and authenticates IMAP client "1"
And IMAP client "1" eventually sees the following message in "Sent" with this structure:
"""
{
"date": "01 Jan 01 00:00 +0000",
"to": "Internal Bridge Test <[user:to]@[domain]>",
"from": "Bridge Test <[user:user]@[domain]>",
"subject": "MESSAGE WITH REMOTE CONTENT SENT",
"content": {
"content-type": "text/html",
"content-type-charset": "utf-8",
"transfer-encoding": "quoted-printable",
"body-is": "<!DOCTYPE html><html><head>\n\n <meta http-equiv=\"content-type\" content=\"text/html; charset=UTF-8\"/>\n </head>\n <body>\n <p><tt>Remote content</tt></p>\n <p><tt><br/>\n </tt></p>\n <p><img src=\"https://bridgeteam.protontech.ch/bridgeteam/tmp/bridge.jpg\" alt=\"Bridge\" width=\"180\" height=\"180\"/></p>\n <p><br/>\n </p>\n <p><tt>Remote content</tt><br/>\n </p>\n <br/>\n \n\n</body></html>"
}
}
"""
Scenario: Forward a message containing various attachments
When SMTP client "1" sends the following message from "[user:user]@[domain]" to "[user:user2]@[domain]":
"""
Content-Type: multipart/mixed; boundary="------------MQ01Z9UM8OaR9z39TvzDfdIq"
Subject: Fwd: Reply to this message, it has various attachments.
References: <something@protonmail.ch>
To: <[user:user2]@[domain]>
From: <[user:user]@[domain]>
In-Reply-To: <something@protonmail.ch>
X-Forwarded-Message-Id: <something@protonmail.ch>
This is a multi-part message in MIME format.
--------------MQ01Z9UM8OaR9z39TvzDfdIq
Content-Type: text/plain; charset=UTF-8; format=flowed
Content-Transfer-Encoding: 7bit
Forwarding a message with various attachments in it!
-------- Forwarded Message --------
Subject: Reply to this message, it has various attachments.
Date: Thu, 26 Oct 2023 10:41:55 +0000
From: Gjorgji Testing <gorgitesting@protonmail.com>
Reply-To: Gjorgji Testing <gorgitesting@protonmail.com>
To: Gjorgji Test v3 <gorgitesting3@protonmail.com>
For real!
*Gjorgji Testing
TesASID <https://www.youtube.com/watch?v=MifXUbrjYr8>
*
--------------MQ01Z9UM8OaR9z39TvzDfdIq
Content-Type: text/html; charset=UTF-8; name="index.html"
Content-Disposition: attachment; filename="index.html"
Content-Transfer-Encoding: base64
IDwhRE9DVFlQRSBodG1sPg0KPGh0bWw+DQo8aGVhZD4NCjx0aXRsZT5QYWdlIFRpdGxlPC90
aXRsZT4NCjwvaGVhZD4NCjxib2R5Pg0KDQo8aDE+TXkgRmlyc3QgSGVhZGluZzwvaDE+DQo8
cD5NeSBmaXJzdCBwYXJhZ3JhcGguPC9wPg0KDQo8L2JvZHk+DQo8L2h0bWw+IA==
--------------MQ01Z9UM8OaR9z39TvzDfdIq
Content-Type: text/xml; charset=UTF-8; name="testxml.xml"
Content-Disposition: attachment; filename="testxml.xml"
Content-Transfer-Encoding: base64
PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPCFET0NUWVBFIHN1aXRl
IFNZU1RFTSAiaHR0cDovL3Rlc3RuZy5vcmcvdGVzdG5nLTEuMC5kdGQiID4KCjxzdWl0ZSBu
YW1lPSJBZmZpbGlhdGUgTmV0d29ya3MiPgoKICAgIDx0ZXN0IG5hbWU9IkFmZmlsaWF0ZSBO
ZXR3b3JrcyIgZW5hYmxlZD0idHJ1ZSI+CiAgICAgICAgPGNsYXNzZXM+CiAgICAgICAgICAg
IDxjbGFzcyBuYW1lPSJjb20uY2xpY2tvdXQuYXBpdGVzdGluZy5hZmZOZXR3b3Jrcy5Bd2lu
VUtUZXN0Ii8+CiAgICAgICAgPC9jbGFzc2VzPgogICAgPC90ZXN0PgoKPC9zdWl0ZT4=
--------------MQ01Z9UM8OaR9z39TvzDfdIq
Content-Type: application/pdf; name="test.pdf"
Content-Disposition: attachment; filename="test.pdf"
Content-Transfer-Encoding: base64
JVBERi0xLjUKJeLjz9MKNyAwIG9iago8PAovVHlwZSAvRm9udERlc2NyaXB0b3IKL0ZvbnRO
MjM0NAolJUVPRgo=
--------------MQ01Z9UM8OaR9z39TvzDfdIq
Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;
name="test.xlsx"
Content-Disposition: attachment; filename="test.xlsx"
Content-Transfer-Encoding: base64
UEsDBBQABgAIAAAAIQBi7p1oXgEAAJAEAAATAAgCW0NvbnRlbnRfVHlwZXNdLnhtbCCiBAIo
UQIAABEAAAAAAAAAAAAAAAAARBcAAGRvY1Byb3BzL2NvcmUueG1sUEsBAi0AFAAGAAgAAAAh
AGFJCRCJAQAAEQMAABAAAAAAAAAAAAAAAAAAvBkAAGRvY1Byb3BzL2FwcC54bWxQSwUGAAAA
AAoACgCAAgAAexwAAAAA
--------------MQ01Z9UM8OaR9z39TvzDfdIq
Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document;
name="test.docx"
Content-Disposition: attachment; filename="test.docx"
Content-Transfer-Encoding: base64
UEsDBBQABgAIAAAAIQDfpNJsWgEAACAFAAATAAgCW0NvbnRlbnRfVHlwZXNdLnhtbCCiBAIo
cHAueG1sUEsBAi0AFAAGAAgAAAAhABA0tG9uAQAA4QIAABEAAAAAAAAAAAAAAAAA2xsAAGRv
Y1Byb3BzL2NvcmUueG1sUEsBAi0AFAAGAAgAAAAhAJ/mlBIqCwAAU3AAAA8AAAAAAAAAAAAA
AAAAgB4AAHdvcmQvc3R5bGVzLnhtbFBLBQYAAAAACwALAMECAADXKQAAAAA=
--------------MQ01Z9UM8OaR9z39TvzDfdIq
Content-Type: text/plain; charset=UTF-8; name="text file.txt"
Content-Disposition: attachment; filename="text file.txt"
Content-Transfer-Encoding: base64
dGV4dCBmaWxl
--------------MQ01Z9UM8OaR9z39TvzDfdIq--
"""
Then it succeeds
When user "[user:user]" connects and authenticates IMAP client "1"
Then IMAP client "1" eventually sees the following messages in "Sent":
| from | to | subject | X-Forwarded-Message-Id |
| [user:user]@[domain] | [user:user2]@[domain] | Fwd: Reply to this message, it has various attachments. | something@protonmail.ch |
And IMAP client "1" eventually sees 1 messages in "Sent"
When the user logs in with username "[user:user2]" and password "password"
And user "[user:user2]" connects and authenticates IMAP client "2"
And user "[user:user2]" finishes syncing
And it succeeds
Then IMAP client "2" eventually sees the following messages in "Inbox":
| from | to | subject | X-Forwarded-Message-Id |
| [user:user]@[domain] | [user:user2]@[domain] | Fwd: Reply to this message, it has various attachments. | something@protonmail.ch |
Then IMAP client "2" eventually sees the following message in "Inbox" with this structure:
"""
{
"from": "[user:user]@[domain]",
"to": "[user:user2]@[domain]",
"subject": "Fwd: Reply to this message, it has various attachments.",
"content": {
"content-type": "multipart/mixed",
"sections":[
{
"content-type": "text/plain",
"content-type-charset": "utf-8",
"transfer-encoding": "quoted-printable",
"body-is": "Forwarding a message with various attachments in it!\r\n\r\n\r\n\r\n-------- Forwarded Message --------\r\nSubject: \tReply to this message, it has various attachments.\r\nDate: \tThu, 26 Oct 2023 10:41:55 +0000\r\nFrom: \tGjorgji Testing <gorgitesting@protonmail.com>\r\nReply-To: \tGjorgji Testing <gorgitesting@protonmail.com>\r\nTo: \tGjorgji Test v3 <gorgitesting3@protonmail.com>\r\n\r\n\r\n\r\n\r\nFor real!\r\n\r\n*Gjorgji Testing\r\nTesASID <https://www.youtube.com/watch?v=3DMifXUbrjYr8>\r\n*"
},
{
"content-type": "text/html",
"content-type-name": "index.html",
"content-disposition": "attachment",
"content-disposition-filename": "index.html",
"transfer-encoding": "base64"
},
{
"content-type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"content-type-name": "test.docx",
"content-disposition": "attachment",
"content-disposition-filename": "test.docx",
"transfer-encoding": "base64"
},
{
"content-type": "application/pdf",
"content-type-name": "test.pdf",
"content-disposition": "attachment",
"content-disposition-filename": "test.pdf",
"transfer-encoding": "base64"
},
{
"content-type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"content-type-name": "test.xlsx",
"content-disposition": "attachment",
"content-disposition-filename": "test.xlsx",
"transfer-encoding": "base64"
},
{
"content-type": "text/xml",
"content-type-name": "testxml.xml",
"content-disposition": "attachment",
"content-disposition-filename": "testxml.xml",
"transfer-encoding": "base64"
},
{
"content-type": "text/plain",
"content-type-name": "text file.txt",
"content-disposition": "attachment",
"content-disposition-filename": "text file.txt",
"transfer-encoding": "base64",
"body-is": "dGV4dCBmaWxl"
}
]
}
}
"""

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,19 @@
Feature: Account settings
Background:
Given there exists an account with username "[user:user]" and password "password"
Then it succeeds
When bridge starts
Scenario: Check account default settings
Then the account "[user:user]" matches the following settings:
| DraftMIMEType | AttachPublicKey | Sign | PGPScheme |
| text/html | false | 0 | 0 |
When the account "[user:user]" has public key attachment "enabled"
And the account "[user:user]" has sign external messages "enabled"
And the account "[user:user]" has default draft format "plain"
And the account "[user:user]" has default PGP schema "inline"
Then the account "[user:user]" matches the following settings:
| DraftMIMEType | AttachPublicKey | Sign | PGPScheme |
| text/plain | true | 1 | 8 |

View File

@ -41,7 +41,7 @@ Feature: The user reports a problem
"Description": "Testing Description",
"Username": "[user:user]",
"Email": "[user:user]@[domain]",
"Client": "Apple Mail"
"EmailClient": "Apple Mail"
}
"""
Then the header in the "POST" multipart request to "/core/v4/reports/bug" has "Title" set to "[Bridge] Bug - Testing Title"

View File

@ -114,6 +114,7 @@ func (s *scenario) steps(ctx *godog.ScenarioContext) {
ctx.Step(`^the account "([^"]*)" has sign external messages "([^"]*)"`, s.accountHasSignExternalMessages)
ctx.Step(`^the account "([^"]*)" has default draft format "([^"]*)"`, s.accountHasDefaultDraftFormat)
ctx.Step(`^the account "([^"]*)" has default PGP schema "([^"]*)"`, s.accountHasDefaultPGPSchema)
ctx.Step(`^the account "([^"]*)" matches the following settings:$`, s.accountMatchesSettings)
// ==== IMAP ====
ctx.Step(`^user "([^"]*)" connects IMAP client "([^"]*)"$`, s.userConnectsIMAPClient)

View File

@ -0,0 +1,27 @@
From: Bridge Test <bridgetest@pm.test>
Date: 01 Jan 1980 00:00:00 +0000
To: Internal Bridge <bridgetest@protonmail.com>
Subject: Message with attachment name
Content-type: multipart/mixed; boundary="boundary"
Received: by 2002:0:0:0:0:0:0:0 with SMTP id 0123456789abcdef; Wed, 30 Dec 2020 01:23:45 0000
This is a multi-part message in MIME format.
--boundary
Content-Type: text/plain
Hello
--boundary
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: 7bit
<h1> HELLO </h1>
--boundary
Content-Type: application/pdf; name="=?US-ASCII?Q?filename?="
Content-Disposition: attachment; filename="=?US-ASCII?Q?filename?="
somebytes
--boundary--

View File

@ -0,0 +1,27 @@
From: Bridge Test <bridgetest@pm.test>
Date: 01 Jan 1980 00:00:00 +0000
To: Internal Bridge <bridgetest@protonmail.com>
Subject: Message with attachment name
Content-type: multipart/mixed; boundary="boundary"
Received: by 2002:0:0:0:0:0:0:0 with SMTP id 0123456789abcdef; Wed, 30 Dec 2020 01:23:45 0000
This is a multi-part message in MIME format.
--boundary
Content-Type: text/plain
Hello
--boundary
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: 7bit
<h1> HELLO </h1>
--boundary
Content-Type: application/pdf; name==?US-ASCII?Q?filename?=
Content-Disposition: attachment; filename==?US-ASCII?Q?filename?=
somebytes
--boundary--

View File

@ -0,0 +1,27 @@
From: Bridge Test <bridgetest@pm.test>
Date: 01 Jan 1980 00:00:00 +0000
To: Internal Bridge <bridgetest@protonmail.com>
Subject: Message with attachment name
Content-type: multipart/mixed; boundary="boundary"
Received: by 2002:0:0:0:0:0:0:0 with SMTP id 0123456789abcdef; Wed, 30 Dec 2020 01:23:45 0000
This is a multi-part message in MIME format.
--boundary
Content-Type: text/plain
Hello
--boundary
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: 7bit
<h1> HELLO </h1>
--boundary
Content-Type: application/pdf; name=filename
Content-Disposition: attachment; filename=filename
somebytes
--boundary--

View File

@ -28,7 +28,10 @@ import (
"time"
"github.com/ProtonMail/gluon/rfc822"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/pkg/message"
"github.com/ProtonMail/proton-bridge/v3/pkg/message/parser"
pmmime "github.com/ProtonMail/proton-bridge/v3/pkg/mime"
"github.com/bradenaw/juniper/xslices"
"github.com/cucumber/messages-go/v16"
"github.com/emersion/go-imap"
@ -202,10 +205,16 @@ func newMessageStructFromIMAP(msg *imap.Message) MessageStruct {
panic(err)
}
m, err := message.Parse(bytes.NewReader(literal))
parser, err := parser.New(bytes.NewReader(literal))
if err != nil {
panic(err)
}
m, err := message.ParseWithParser(parser, true)
if err != nil {
panic(err)
}
var body string
switch {
case m.MIMEType == rfc822.TextPlain:
@ -245,34 +254,23 @@ func formatAddressList(list []*imap.Address) string {
}
func parseMessageSection(literal []byte, body string) MessageSection {
mimeType, boundary, charset, name := parseContentType(literal)
headers, err := rfc822.Parse(literal).ParseHeader()
if err != nil {
panic(err)
}
msgSect := MessageSection{
ContentType: string(mimeType),
ContentTypeBoundary: boundary,
ContentTypeCharset: charset,
ContentTypeName: name,
TransferEncoding: headers.Get("content-transfer-encoding"),
BodyIs: body,
}
mimeType, boundary, charset, name := parseContentType(headers.Get("Content-Type"))
disp, filename := parseContentDisposition(headers.Get("Content-Disposition"))
contentDisposition := bytes.Split([]byte(headers.Get("content-disposition")), []byte(";"))
for id, value := range contentDisposition {
if id == 0 {
msgSect.ContentDisposition = strings.TrimSpace(string(value))
continue
}
param := bytes.Split(value, []byte("="))
if strings.TrimSpace(string(param[0])) == "filename" && len(param) >= 2 {
_, filename, _ := strings.Cut(string(value), "filename=")
filename = strings.Trim(filename, "\"")
msgSect.ContentDispositionFilename = strings.TrimSpace(filename)
}
msgSect := MessageSection{
ContentType: mimeType,
ContentTypeBoundary: boundary,
ContentTypeCharset: charset,
ContentTypeName: name,
ContentDisposition: disp,
ContentDispositionFilename: filename,
TransferEncoding: headers.Get("content-transfer-encoding"),
BodyIs: body,
}
if msgSect.ContentTypeBoundary != "" {
@ -294,8 +292,8 @@ func parseMessageSection(literal []byte, body string) MessageSection {
return msgSect
}
func parseContentType(literal []byte) (rfc822.MIMEType, string, string, string) {
mimeType, params, err := rfc822.Parse(literal).ContentType()
func parseContentType(contentType string) (string, string, string, string) {
mimeType, params, err := pmmime.ParseMediaType(contentType)
if err != nil {
panic(err)
}
@ -314,6 +312,15 @@ func parseContentType(literal []byte) (rfc822.MIMEType, string, string, string)
return mimeType, boundary, charset, name
}
func parseContentDisposition(contentDisp string) (string, string) {
disp, params, _ := pmmime.ParseMediaType(contentDisp)
name, ok := params["filename"]
if !ok {
name = ""
}
return disp, name
}
func matchMessages(have, want []Message) error {
slices.SortFunc(have, func(a, b Message) bool {
return a.Subject < b.Subject
@ -331,70 +338,77 @@ func matchMessages(have, want []Message) error {
}
func matchStructure(have []MessageStruct, want MessageStruct) error {
mismatches := make([]string, 0)
for _, msg := range have {
if want.From != "" && msg.From != want.From {
mismatches = append(mismatches, "From")
continue
}
if want.To != "" && msg.To != want.To {
mismatches = append(mismatches, "To")
continue
}
if want.BCC != "" && msg.BCC != want.BCC {
mismatches = append(mismatches, "BCC")
continue
}
if want.CC != "" && msg.CC != want.CC {
mismatches = append(mismatches, "CC")
continue
}
if want.Subject != "" && msg.Subject != want.Subject {
mismatches = append(mismatches, "Subject")
continue
}
if want.Date != "" && want.Date != msg.Date {
mismatches = append(mismatches, "Date")
continue
}
if matchContent(msg.Content, want.Content) {
return nil
if ok, mismatch := matchContent(msg.Content, want.Content); !ok {
mismatches = append(mismatches, "Content: "+mismatch)
continue
}
return nil
}
return fmt.Errorf("missing messages: have %#v, want %#v", have, want)
return fmt.Errorf("missing messages: have %#v, want %#v with mismatch list %#v", have, want, mismatches)
}
func matchContent(have MessageSection, want MessageSection) bool {
func matchContent(have MessageSection, want MessageSection) (bool, string) {
if want.ContentType != "" && want.ContentType != have.ContentType {
return false
return false, "ContentType"
}
if want.ContentTypeBoundary != "" && want.ContentTypeBoundary != have.ContentTypeBoundary {
return false
return false, "ContentTypeBoundary"
}
if want.ContentTypeCharset != "" && want.ContentTypeCharset != have.ContentTypeCharset {
return false
return false, "ContentTypeCharset"
}
if want.ContentTypeName != "" && want.ContentTypeName != have.ContentTypeName {
return false
return false, "ContentTypeName"
}
if want.ContentDisposition != "" && want.ContentDisposition != have.ContentDisposition {
return false
return false, "ContentDisposition"
}
if want.ContentDispositionFilename != "" && want.ContentDispositionFilename != have.ContentDispositionFilename {
return false
return false, "ContentDispositionFilename"
}
if want.TransferEncoding != "" && want.TransferEncoding != have.TransferEncoding {
return false
return false, "TransferEncoding"
}
if want.BodyContains != "" && !strings.Contains(strings.TrimSpace(have.BodyIs), strings.TrimSpace(want.BodyContains)) {
return false
return false, "BodyContains"
}
if want.BodyIs != "" && strings.TrimSpace(have.BodyIs) != strings.TrimSpace(want.BodyIs) {
return false
}
if len(have.Sections) != len(want.Sections) {
return false
return false, "BodyIs"
}
for i, section := range want.Sections {
if !matchContent(have.Sections[i], section) {
return false
if ok, mismatch := matchContent(have.Sections[i], section); !ok {
return false, fmt.Sprintf("section %#v - %#v", i, mismatch)
}
}
return true
return true, ""
}
type Mailbox struct {
@ -551,3 +565,10 @@ type Contact struct {
Sign string `bdd:"signature"`
Encrypt string `bdd:"encryption"`
}
type MailSettings struct {
DraftMIMEType rfc822.MIMEType `bdd:"DraftMIMEType"`
AttachPublicKey proton.Bool `bdd:"AttachPublicKey"`
Sign proton.SignExternalMessages `bdd:"Sign"`
PGPScheme proton.EncryptionScheme `bdd:"PGPScheme"`
}

View File

@ -643,3 +643,29 @@ func (s *scenario) accountHasDefaultPGPSchema(account, schema string) error {
return err
})
}
func (s *scenario) accountMatchesSettings(account string, table *godog.Table) error {
return s.t.withClient(context.Background(), account, func(ctx context.Context, c *proton.Client) error {
wantSettings, err := unmarshalTable[MailSettings](table)
if err != nil {
return err
}
settings, err := c.GetMailSettings(ctx)
if err != nil {
return err
}
if len(wantSettings) != 1 {
return errors.New("this step only supports one settings definition at a time")
}
return matchSettings(settings, wantSettings[0])
})
}
func matchSettings(have proton.MailSettings, want MailSettings) error {
if !IsSub(ToAny(have), ToAny(want)) {
return fmt.Errorf("missing mailsettings: have %#v, want %#v", have, want)
}
return nil
}

View File

@ -0,0 +1,37 @@
// Copyright (c) 2023 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 main
import (
"fmt"
"os"
"github.com/ProtonMail/proton-bridge/v3/internal/user"
)
func main() {
if len(os.Args) < 2 {
fmt.Printf("Usage: %v <dump dir>\n", os.Args[0])
return
}
if err := user.TryBuildDebugMessage(os.Args[1]); err != nil {
fmt.Printf("%v\n", err.Error())
return
}
}