Compare commits

..

34 Commits
v3.6.0 ... v3

Author SHA1 Message Date
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
61 changed files with 3376 additions and 411 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:

20
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.20231009084701-3af0474b0b3c
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.20231011132529-24b5b817ee1f
github.com/ProtonMail/gopenpgp/v2 v2.7.3-proton
github.com/ProtonMail/go-proton-api v0.4.1-0.20231030091225-8fc2478b27f4
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,10 +43,10 @@ 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
)
@ -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,11 +110,11 @@ 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
)

39
go.sum
View File

@ -23,8 +23,8 @@ 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.20231009084701-3af0474b0b3c h1:gUDu4pOswgbou0QczfreNiXQFrmvVlpSh8Q+vft/JvI=
github.com/ProtonMail/gluon v0.17.1-0.20231009084701-3af0474b0b3c/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=
@ -34,12 +34,12 @@ github.com/ProtonMail/go-message v0.13.1-0.20230526094639-b62c999c85b7 h1:+j+Kd/
github.com/ProtonMail/go-message v0.13.1-0.20230526094639-b62c999c85b7/go.mod h1:NBAn21zgCJ/52WLDyed18YvYFm5tEoeDauubFqLokM4=
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k=
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw=
github.com/ProtonMail/go-proton-api v0.4.1-0.20231011132529-24b5b817ee1f h1:n0oBMAz2dJhn5+1WA6NrjkWqkZN+22FQMkPlRwNGhpU=
github.com/ProtonMail/go-proton-api v0.4.1-0.20231011132529-24b5b817ee1f/go.mod h1:ZmvQMA8hanLiD1tFsvu9+qGBcuxbIRfch/4z/nqBhXA=
github.com/ProtonMail/go-proton-api v0.4.1-0.20231030091225-8fc2478b27f4 h1:1XISoHi1FmaVW3vm/y5FuXmrSMo53U0sM3zZpgczWTc=
github.com/ProtonMail/go-proton-api v0.4.1-0.20231030091225-8fc2478b27f4/go.mod h1:qgCy0LgMJy3bfVYyLljPScdB1bybc2adEkMr9WhBB5c=
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.3-proton h1:wuAxBUU9qF2wyDVJprn/2xPDx000eol5gwlKbOUYY88=
github.com/ProtonMail/gopenpgp/v2 v2.7.3-proton/go.mod h1:omVkSsfPAhmptzPF/piMXb16wKIWUvVhZbVW7sJKh0A=
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=
@ -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=
@ -421,8 +421,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
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=
@ -472,8 +472,8 @@ 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=
@ -521,8 +521,8 @@ 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=
@ -539,8 +539,9 @@ 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/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -588,13 +589,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

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

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

@ -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,14 +72,25 @@ 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;
}
ColumnLayout {

View File

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

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

@ -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,7 +152,7 @@ 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,
@ -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()
}
}
}
@ -615,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{}

View File

@ -220,7 +220,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 +315,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

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

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

@ -547,8 +547,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 +558,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 +586,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
}

View File

@ -539,6 +539,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")

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

@ -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.Response.Code != proton.SuccessCode {
return errors.New("APIError " + res[0].Response.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,75 @@ 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>"
}
}
"""

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