Compare commits

..

42 Commits
v3 ... v3.7.1

Author SHA1 Message Date
3210709810 chore: Wakato Bridge 3.7.1 changelog. 2023-11-20 11:56:03 +01:00
8fd988d7c5 fix(GODT-3054): Only delete drafts after message has been Sent
When editing a draft created by Apple Mail on the web client and then
later sending the draft with Apple Mail, we need to delete the draft
ourselves, or it will remain in the Draft folder.

This patch makes sure that the deletion of said draft only occurs after
the message was successfully sent.
2023-11-20 10:37:04 +01:00
bf89d548d3 fix(GODT-2576): Correctly handle Forwarded messages from Thunderbird
Thunderbird uses `In-Reply-To` with `X-Forwarded-Message-Id` to signal
to the SMTP server that it is forwarding a message.
2023-11-16 16:17:54 +01:00
51229cbb68 feat(GODT-3122): added test, changed interface for accessing display name. 2023-11-16 10:44:59 +00:00
36c5c37dac fix(GODT-3122): use display name as 'Email Account Name' in macOS profile. 2023-11-16 10:44:59 +00:00
5a434fafbc fix(GODT-3125): Heartbeat crash on exit
Ensure that the heartbeat background task is stopped before we close
the users as it accesses data within these instances.

Additionally, we also make sure that when telemetry is disabled, we stop
the background task.

Finally, `HeartbeatManager` now specifies what the desired interval is
so we can better configure the test cases.
2023-11-16 11:05:40 +01:00
ea1c2534df fix(GODT-2617): Validate user can send from the SMTP sender address
https://github.com/ProtonMail/go-proton-api/pull/126
2023-11-15 14:13:21 +01:00
1cafbfcaaa chore: Wakato Bridge 3.7.1 changelog. 2023-11-15 12:54:18 +01:00
2d44ccaee0 fix(GODT-3123): Trigger bad event on empty EventID on existing accounts
See `checkIrrecoverableEventID` for more details.
2023-11-15 11:06:51 +01:00
96517b7fb1 chore: Remove debug prints 2023-11-15 09:09:07 +01:00
bc381407a7 feat(GODT-2576): Forward and $Forward Flag Support
When an IMAP client stores the `Forward` or `$Forward` flags on a
message, the forwarded state is now correctly represented on the Proton
servers.

https://github.com/ProtonMail/go-proton-api/pull/125
https://github.com/ProtonMail/gluon/pull/400
2023-11-15 07:51:00 +01:00
ddc5e775b9 fix(GODT-3118): Do not reset EventID when migrating sync settings 2023-11-14 07:03:28 +00:00
ea26188dc0 fix(GODT-2277): Fix keychains initialisation in vault-editor (for write as well). 2023-11-13 15:37:32 +01:00
159e1cee7d fix(GODT-2277): Fix keychains initialisation in vault-editor. 2023-11-13 13:58:03 +00:00
4394ad0e9b feat(GODT-3053): use smaller bridge window on small screens. 2023-11-10 14:23:41 +00:00
856bdd1321 fix(GODT-3116): Panic on closed channel
If sync finishes during shutdown, check if there is a context error in
the deferred go routine before rewinding the event.
2023-11-10 14:47:03 +01:00
ff288145df fix(GODT-1623): Throttle SMTP failed requests
If a SMPT client keeps hammering bridge and triggers multiple successive
errors in quick succession, force that client to wait 20 seconds before
trying again.
2023-11-10 12:54:38 +00:00
83bbdbd63e feat(GODT-3113): Only force UTF-8 charset for HTML part when needed. 2023-11-10 12:50:15 +00:00
fa430ee0fb fix(GODT-3047): fixed 'disk full' error message. 2023-11-10 08:57:53 +00:00
0303ba38e8 feat(GODT-3113): Do not render HTML for attachment. 2023-11-10 08:36:46 +00:00
2a78b5c144 feat(GODT-3112): replaced error message when bridge exists prematurely. Added a link to support form. 2023-11-09 12:52:31 +00:00
a00b3cdb92 fix(GODT-3054): Delete draft create from reply
If an IMAP client creates a new message as a reply/forward from an
existing draft, that draft will be deleted once the message has been
sent.

Other than not being the correct behavior, the original reason for which
this line of code was added (carried over from v2), seems to be no longer
necessary as in all tests, the message is correctly removed from the
drafts folder after sent.
2023-11-09 13:24:38 +01:00
8d3e04679f feat(GODT-3010): Do not log error when no MimeType provided to lower the noise. 2023-11-09 09:45:40 +00:00
21ff7b4b97 feat(GODT-2947): Remove 'blame it on the weather' error part from go-smtp. 2023-11-09 09:45:02 +00:00
4ea161f7ad chore(GODT-3010): Log MimeType parsing issue. 2023-11-08 16:21:19 +00:00
dc584ea29b feat(GODT-3104): added log entry for cert install status on startup on macOS. 2023-11-08 16:30:50 +01:00
4a01c46aed fix(GODT-3048): WKD Policy behavior
Ensure Bridge respects the no encrypt setting on a contact which has a
WKD key.
2023-11-08 14:23:36 +01:00
e8d9534b9c feat(GODT-2277): Move Keychain helpers creation in main. 2023-11-08 13:05:57 +00:00
96904b160f test(GODT-2740): Sending Plain text messages to internal recipient 2023-11-07 10:02:26 +00:00
b535be72f8 test(GODT-2892): Create fake log file 2023-11-07 07:21:26 +00:00
40f2d8b30f chore: Wakato Bridge 3.7.0 changelog. 2023-11-06 14:51:20 +01:00
95a1acec0d fix(GODT-3097): Warn about PGPInline encryption scheme which will be deprecated. 2023-11-06 14:29:31 +01:00
5ff074cc49 fix(GODT-3106): Broken import route
https://github.com/ProtonMail/go-proton-api/pull/123
2023-11-06 10:36:27 +01:00
4f0660bb8c chore: Wakato Bridge 3.7.0 changelog. 2023-11-03 17:51:46 +01:00
708184439e chore: update changelog for previous versions. 2023-11-03 17:32:23 +01:00
b8a33b9618 fix(GODT-3041): Fix Invalid Or Missing message signature during send
If we update the address after determining the sender address is
different, we also need to refresh the identity state in order to use
the right encryption keys.
2023-11-03 10:35:36 +01:00
1c385d5c9b fix(GODT-3087): Exclude attachment content-disposition part when determining... 2023-11-03 08:55:01 +00:00
96773f3225 fix(GODT-2887): Inline images with Apple Mail
Fix sending of inline images with Apple Mail when not using rich text.
2023-11-02 14:18:28 +00:00
0f320dbd80 fix(GODT-3100): fix issue where a fatal error that bubble up to cli.Run() is not written in the log file. 2023-11-02 13:36:01 +00:00
6cb233473a fix(GODT-3094): Clean up old update files on bridge startup. 2023-11-02 10:43:55 +01:00
1ac4e70115 test(GODT-1224): Add testing around package creation. 2023-11-02 07:24:43 +00:00
07f93d276b fix(GODT-3012): Fix multipart request retries
Multipart request were failing due a bug in resty, which would cause
retries of the same request to end up with no data passed to the server.

https://github.com/ProtonMail/go-proton-api/pull/120
2023-10-30 15:27:39 +01:00
91 changed files with 4941 additions and 404 deletions

View File

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

View File

@ -3,6 +3,97 @@
Changelog [format](http://keepachangelog.com/en/1.0.0/)
## Wakato Bridge 3.7.1
### Added
* Test(GODT-2740): Sending Plain text messages to internal recipient.
* Test(GODT-2892): Create fake log file.
* GODT-3122: Added test, changed interface for accessing display name.
### Changed
* Remove debug prints.
* GODT-2576: Forward and $Forward Flag Support.
* GODT-3053: Use smaller bridge window on small screens.
* GODT-3113: Only force UTF-8 charset for HTML part when needed.
* GODT-3113: Do not render HTML for attachment.
* GODT-3112: Replaced error message when bridge exists prematurely. Added a link to support form.
* GODT-2947: Remove 'blame it on the weather' error part from go-smtp.
* GODT-3010: Log MimeType parsing issue.
* GODT-3104: Added log entry for cert install status on startup on macOS.
* GODT-2277: Move Keychain helpers creation in main.
### Fixed
* GODT-3054: Only delete drafts after message has been Sent.
* GODT-2576: Correctly handle Forwarded messages from Thunderbird.
* GODT-3122: Use display name as 'Email Account Name' in macOS profile.
* GODT-3125: Heartbeat crash on exit.
* GODT-2617: Validate user can send from the SMTP sender address.
* GODT-3123: Trigger bad event on empty EventID on existing accounts.
* GODT-3118: Do not reset EventID when migrating sync settings.
* GODT-3116: Panic on closed channel.
* GODT-1623: Throttle SMTP failed requests.
* GODT-3047: Fixed 'disk full' error message.
* GODT-3054: Delete draft create from reply.
* GODT-3048: WKD Policy behavior.
## Wakato Bridge 3.7.0
### Added
* Test(GODT-1224): Add testing around package creation.
* Add debug_assemble binary.
* Test(GODT-2723): Add importing a message with remote content.
* Test(GODT-2737): Sending HTML messages to internal.
* Test(GODT-3036): Keep inline attachment order on GPA Fake Server.
* GODT-3015: Add simple algorithm to deal with multiple attachment for bug report.
* Test: make message structure check more verbose.
* Test: Add test around account settings.
### Changed
* GODT-3097: Warn about PGPInline encryption scheme which will be deprecated.
* Test: Support multiple users when waiting for sync event.
* Test: Update fake server with defautl draft content-type and test it.
* Test: be less aggressive while checking for message structure.
* GODT-2996: Set password fields to hidden when resetting the login form.
* GODT-2990: Change runner tags.
* GODT-2835: Bump GPA adding support for AsyncAttachments for BugReport +...
* GODT-2940: Allow 3 attempts for mailbox password.
* GODT-3095: Update GOpenPGP.
### Fixed
* GODT-3106: Broken import route.
* GODT-3041: Fix Invalid Or Missing message signature during send.
* GODT-3087: Exclude attachment content-disposition part when determining...
* GODT-2887: Inline images with Apple Mail.
* GODT-3100: Fix issue where a fatal error that bubble up to cli.Run() is not written in the log file.
* GODT-3094: Clean up old update files on bridge startup.
* GODT-3012: Fix multipart request retries.
* GODT-2935: Do not allow parentID into drafts.
* GODT-2935: Correct error message when draft fails to create.
* GODT-2970: Correctly handle rename of Inbox.
* GODT-2969: Prevent duration corruption for config status event.
* Fixed type in QA installer CI job name.
* GODT-3019: Fix title of main window when no account is connected.
* GODT-3013: IMAP service getting "stuck".
* GODT-2966: Allow permissive parsing of MediaType parameters for import.
* GODT-2966: Add more test regarding quoted/unquoted filename in attachment.
* GODT-2490: Fix sync progress not being reset when toggling split mode.
* GODT-2515: Customized notification of unavailable keychain on macOS.
## Vasco da Gama Bridge 3.6.1
### Fixed
* GODT-3033: Unable to receive new mail.
## Umshiang Bridge 3.5.4
### Fixed
* GODT-3033: Unable to receive new mail.
## Vasco da Gama Bridge 3.6.0
### Added

View File

@ -11,7 +11,7 @@ ROOT_DIR:=$(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))
.PHONY: build build-gui build-nogui build-launcher versioner hasher
# Keep version hardcoded so app build works also without Git repository.
BRIDGE_APP_VERSION?=3.6.0+git
BRIDGE_APP_VERSION?=3.7.1+git
APP_VERSION:=${BRIDGE_APP_VERSION}
APP_FULL_NAME:=Proton Mail Bridge
APP_VENDOR:=Proton AG

View File

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

6
go.mod
View File

@ -5,9 +5,9 @@ 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.20231025125916-5c7941465df8
github.com/ProtonMail/gluon v0.17.1-0.20231114153341-2ecbdd2739f7
github.com/ProtonMail/go-autostart v0.0.0-20210130080809-00ed301c8e9a
github.com/ProtonMail/go-proton-api v0.4.1-0.20231030091225-8fc2478b27f4
github.com/ProtonMail/go-proton-api v0.4.1-0.20231116144214-8a47c8d92fbc
github.com/ProtonMail/gopenpgp/v2 v2.7.4-proton
github.com/PuerkitoBio/goquery v1.8.1
github.com/abiosoft/ishell v2.0.0+incompatible
@ -121,5 +121,7 @@ require (
replace (
github.com/docker/docker-credential-helpers => github.com/ProtonMail/docker-credential-helpers v1.1.0
github.com/emersion/go-message => github.com/ProtonMail/go-message v0.13.1-0.20230526094639-b62c999c85b7
github.com/emersion/go-smtp => github.com/ProtonMail/go-smtp v0.0.0-20231109081432-2b3d50599865
github.com/go-resty/resty/v2 => github.com/LBeernaertProton/resty/v2 v2.0.0-20231030122409-92db8bee3605
github.com/keybase/go-keychain => github.com/cuthix/go-keychain v0.0.0-20230517073537-fc1740a83768
)

23
go.sum
View File

@ -15,6 +15,8 @@ github.com/0xAX/notificator v0.0.0-20220220101646-ee9b8921e557 h1:l6surSnJ3RP4qA
github.com/0xAX/notificator v0.0.0-20220220101646-ee9b8921e557/go.mod h1:sTrmvD/TxuypdOERsDOS7SndZg0rzzcCi1b6wQMXUYM=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/LBeernaertProton/resty/v2 v2.0.0-20231030122409-92db8bee3605 h1:54Fh3JS6s2Tjy6ZIRLtt1amZOqfYDcjErdye45z8fkQ=
github.com/LBeernaertProton/resty/v2 v2.0.0-20231030122409-92db8bee3605/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A=
github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g=
github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
@ -23,8 +25,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.20231025125916-5c7941465df8 h1:sG0o5pEoS2z2jNR9zK7Juq5Tr3X+GfHmQ8L99RPowaE=
github.com/ProtonMail/gluon v0.17.1-0.20231025125916-5c7941465df8/go.mod h1:Og5/Dz1MiGpCJn51XujZwxiLG7WzvvjE5PRpZBQmAHo=
github.com/ProtonMail/gluon v0.17.1-0.20231114153341-2ecbdd2739f7 h1:w+VoSAq9FQvKMm3DlH1MIEZ1KGe7LJ+81EJFVwSV4VU=
github.com/ProtonMail/gluon v0.17.1-0.20231114153341-2ecbdd2739f7/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,8 +36,10 @@ github.com/ProtonMail/go-message v0.13.1-0.20230526094639-b62c999c85b7 h1:+j+Kd/
github.com/ProtonMail/go-message v0.13.1-0.20230526094639-b62c999c85b7/go.mod h1:NBAn21zgCJ/52WLDyed18YvYFm5tEoeDauubFqLokM4=
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k=
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw=
github.com/ProtonMail/go-proton-api v0.4.1-0.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-proton-api v0.4.1-0.20231116144214-8a47c8d92fbc h1:GBRKoFAldApEMkMrsFN1ZxG0eG797w6LTv/dFMDcsqQ=
github.com/ProtonMail/go-proton-api v0.4.1-0.20231116144214-8a47c8d92fbc/go.mod h1:WEXJqj5DSc2YI77SgXdpMY0nk33Qy92Vu2r4tOEazA8=
github.com/ProtonMail/go-smtp v0.0.0-20231109081432-2b3d50599865 h1:EP1gnxLL5Z7xBSymE9nSTM27nRYINuvssAtDmG0suD8=
github.com/ProtonMail/go-smtp v0.0.0-20231109081432-2b3d50599865/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/ProtonMail/go-srp v0.0.7 h1:Sos3Qk+th4tQR64vsxGIxYpN3rdnG9Wf9K4ZloC1JrI=
github.com/ProtonMail/go-srp v0.0.7/go.mod h1:giCp+7qRnMIcCvI6V6U3S1lDDXDQYx2ewJ6F/9wdlJk=
github.com/ProtonMail/gopenpgp/v2 v2.7.4-proton h1:8tqHYM6IGsdEc6Vxf1TWiwpHNj8yIEQNACPhxsDagrk=
@ -120,8 +124,6 @@ github.com/emersion/go-imap-id v0.0.0-20190926060100-f94a56b9ecde/go.mod h1:sPwp
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y=
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.15.1-0.20221021114529-49b17434419d h1:hFRM6zCBSc+Xa0rBOqSlG6Qe9dKC/2vLhGAuZlWxTsc=
github.com/emersion/go-smtp v0.15.1-0.20221021114529-49b17434419d/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
github.com/emersion/go-vcard v0.0.0-20230331202150-f3d26859ccd3 h1:hQ1wTMaKcGfobYRT88RM8NFNyX+IQHvagkm/tqViU98=
@ -155,8 +157,6 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-resty/resty/v2 v2.7.0 h1:me+K9p3uhSmXtrBZ4k9jcEAfJmuC8IivWHwaLZwPrFY=
github.com/go-resty/resty/v2 v2.7.0/go.mod h1:9PWDzw47qPphMRFfhsyk0NnSgvluHcljSMVIq3w7q0I=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
@ -465,13 +465,13 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/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=
@ -521,6 +521,7 @@ 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/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=
@ -529,6 +530,8 @@ golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
@ -544,6 +547,8 @@ golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@ -41,6 +41,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/sentry"
"github.com/ProtonMail/proton-bridge/v3/internal/useragent"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/pkg/keychain"
"github.com/ProtonMail/proton-bridge/v3/pkg/restarter"
"github.com/pkg/profile"
"github.com/sirupsen/logrus"
@ -204,7 +205,7 @@ func run(c *cli.Context) error {
}()
// Restart the app if requested.
return withRestarter(exe, func(restarter *restarter.Restarter) error {
err = withRestarter(exe, func(restarter *restarter.Restarter) error {
// Handle crashes with various actions.
return withCrashHandler(restarter, reporter, func(crashHandler *crash.Handler, quitCh <-chan struct{}) error {
migrationErr := migrateOldVersions()
@ -234,53 +235,56 @@ func run(c *cli.Context) error {
}
return withSingleInstance(settings, locations.GetLockFile(), version, func() error {
// Unlock the encrypted vault.
return WithVault(locations, crashHandler, func(v *vault.Vault, insecure, corrupt bool) error {
if !v.Migrated() {
// Migrate old settings into the vault.
if err := migrateOldSettings(v); err != nil {
logrus.WithError(err).Error("Failed to migrate old settings")
}
// Migrate old accounts into the vault.
if err := migrateOldAccounts(locations, v); err != nil {
logrus.WithError(err).Error("Failed to migrate old accounts")
}
// The vault has been migrated.
if err := v.SetMigrated(); err != nil {
logrus.WithError(err).Error("Failed to mark vault as migrated")
}
}
logrus.WithFields(logrus.Fields{
"lastVersion": v.GetLastVersion().String(),
"showAllMail": v.GetShowAllMail(),
"updateCh": v.GetUpdateChannel(),
"autoUpdate": v.GetAutoUpdate(),
"rollout": v.GetUpdateRollout(),
"DoH": v.GetProxyAllowed(),
}).Info("Vault loaded")
// Load the cookies from the vault.
return withCookieJar(v, func(cookieJar http.CookieJar) error {
// Create a new bridge instance.
return withBridge(c, exe, locations, version, identifier, crashHandler, reporter, v, cookieJar, func(b *bridge.Bridge, eventCh <-chan events.Event) error {
if insecure {
logrus.Warn("The vault key could not be retrieved; the vault will not be encrypted")
b.PushError(bridge.ErrVaultInsecure)
// Look for available keychains
return WithKeychainList(func(keychains *keychain.List) error {
// Unlock the encrypted vault.
return WithVault(locations, keychains, crashHandler, func(v *vault.Vault, insecure, corrupt bool) error {
if !v.Migrated() {
// Migrate old settings into the vault.
if err := migrateOldSettings(v); err != nil {
logrus.WithError(err).Error("Failed to migrate old settings")
}
if corrupt {
logrus.Warn("The vault is corrupt and has been wiped")
b.PushError(bridge.ErrVaultCorrupt)
// Migrate old accounts into the vault.
if err := migrateOldAccounts(locations, keychains, v); err != nil {
logrus.WithError(err).Error("Failed to migrate old accounts")
}
// Start telemetry heartbeat process
b.StartHeartbeat(b)
// The vault has been migrated.
if err := v.SetMigrated(); err != nil {
logrus.WithError(err).Error("Failed to mark vault as migrated")
}
}
// Run the frontend.
return runFrontend(c, crashHandler, restarter, locations, b, eventCh, quitCh, c.Int(flagParentPID))
logrus.WithFields(logrus.Fields{
"lastVersion": v.GetLastVersion().String(),
"showAllMail": v.GetShowAllMail(),
"updateCh": v.GetUpdateChannel(),
"autoUpdate": v.GetAutoUpdate(),
"rollout": v.GetUpdateRollout(),
"DoH": v.GetProxyAllowed(),
}).Info("Vault loaded")
// Load the cookies from the vault.
return withCookieJar(v, func(cookieJar http.CookieJar) error {
// Create a new bridge instance.
return withBridge(c, exe, locations, version, identifier, crashHandler, reporter, v, cookieJar, keychains, func(b *bridge.Bridge, eventCh <-chan events.Event) error {
if insecure {
logrus.Warn("The vault key could not be retrieved; the vault will not be encrypted")
b.PushError(bridge.ErrVaultInsecure)
}
if corrupt {
logrus.Warn("The vault is corrupt and has been wiped")
b.PushError(bridge.ErrVaultCorrupt)
}
// Remove old updates files
b.RemoveOldUpdates()
// Run the frontend.
return runFrontend(c, crashHandler, restarter, locations, b, eventCh, quitCh, c.Int(flagParentPID))
})
})
})
})
@ -290,6 +294,13 @@ func run(c *cli.Context) error {
})
})
})
// if an error occurs, it must be logged now because we're about to close the log file.
if err != nil {
logrus.Fatal(err)
}
return err
}
// If there's another instance already running, try to raise it and exit.
@ -470,6 +481,13 @@ func withCookieJar(vault *vault.Vault, fn func(http.CookieJar) error) error {
return fn(persister)
}
// WithKeychainList init the list of usable keychains.
func WithKeychainList(fn func(*keychain.List) error) error {
logrus.Debug("Creating keychain list")
defer logrus.Debug("Keychain list stop")
return fn(keychain.NewList())
}
func setDeviceCookies(jar *cookies.Jar) error {
url, err := url.Parse(constants.APIHost)
if err != nil {

View File

@ -37,6 +37,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/useragent"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/internal/versioner"
"github.com/ProtonMail/proton-bridge/v3/pkg/keychain"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
@ -55,6 +56,7 @@ func withBridge(
reporter *sentry.Reporter,
vault *vault.Vault,
cookieJar http.CookieJar,
keychains *keychain.List,
fn func(*bridge.Bridge, <-chan events.Event) error,
) error {
logrus.Debug("Creating bridge")
@ -97,6 +99,7 @@ func withBridge(
autostarter,
updater,
version,
keychains,
// The API stuff.
constants.APIHost,
@ -110,6 +113,7 @@ func withBridge(
crashHandler,
reporter,
imap.DefaultEpochUIDValidityGenerator(),
nil,
// The logging stuff.
c.String(flagLogIMAP) == "client" || c.String(flagLogIMAP) == "all",
@ -155,7 +159,7 @@ func newUpdater(locations *locations.Locations) (*updater.Updater, error) {
}
return updater.NewUpdater(
updater.NewInstaller(versioner.New(updatesDir)),
versioner.New(updatesDir),
verifier,
constants.UpdateName,
runtime.GOOS,

View File

@ -122,7 +122,7 @@ func migrateOldSettingsWithDir(configDir string, v *vault.Vault) error {
return v.SetBridgeTLSCertKey(certPEM, keyPEM)
}
func migrateOldAccounts(locations *locations.Locations, v *vault.Vault) error {
func migrateOldAccounts(locations *locations.Locations, keychains *keychain.List, v *vault.Vault) error {
logrus.Info("Migrating accounts")
settings, err := locations.ProvideSettingsPath()
@ -134,8 +134,7 @@ func migrateOldAccounts(locations *locations.Locations, v *vault.Vault) error {
if err != nil {
return fmt.Errorf("failed to get helper: %w", err)
}
keychain, err := keychain.NewKeychain(helper, "bridge")
keychain, err := keychain.NewKeychain(helper, "bridge", keychains.GetHelpers(), keychains.GetDefaultHelper())
if err != nil {
return fmt.Errorf("failed to create keychain: %w", err)
}

View File

@ -35,7 +35,6 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/pkg/algo"
"github.com/ProtonMail/proton-bridge/v3/pkg/keychain"
dockerCredentials "github.com/docker/docker-credential-helpers/credentials"
"github.com/stretchr/testify/require"
)
@ -133,11 +132,9 @@ func TestKeychainMigration(t *testing.T) {
}
func TestUserMigration(t *testing.T) {
keychainHelper := keychain.NewTestHelper()
kcl := keychain.NewTestKeychainsList()
keychain.Helpers["mock"] = func(string) (dockerCredentials.Helper, error) { return keychainHelper, nil }
kc, err := keychain.NewKeychain("mock", "bridge")
kc, err := keychain.NewKeychain("mock", "bridge", kcl.GetHelpers(), kcl.GetDefaultHelper())
require.NoError(t, err)
require.NoError(t, kc.Put("brokenID", "broken"))
@ -178,7 +175,7 @@ func TestUserMigration(t *testing.T) {
require.NoError(t, err)
require.False(t, corrupt)
require.NoError(t, migrateOldAccounts(locations, v))
require.NoError(t, migrateOldAccounts(locations, kcl, v))
require.Equal(t, []string{wantCredentials.UserID}, v.GetUserIDs())
require.NoError(t, v.GetUser(wantCredentials.UserID, func(u *vault.User) {

View File

@ -22,6 +22,7 @@ import (
"path"
"github.com/ProtonMail/gluon/async"
"github.com/ProtonMail/proton-bridge/v3/internal/certs"
"github.com/ProtonMail/proton-bridge/v3/internal/constants"
"github.com/ProtonMail/proton-bridge/v3/internal/locations"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
@ -29,12 +30,12 @@ import (
"github.com/sirupsen/logrus"
)
func WithVault(locations *locations.Locations, panicHandler async.PanicHandler, fn func(*vault.Vault, bool, bool) error) error {
func WithVault(locations *locations.Locations, keychains *keychain.List, panicHandler async.PanicHandler, fn func(*vault.Vault, bool, bool) error) error {
logrus.Debug("Creating vault")
defer logrus.Debug("Vault stopped")
// Create the encVault.
encVault, insecure, corrupt, err := newVault(locations, panicHandler)
encVault, insecure, corrupt, err := newVault(locations, keychains, panicHandler)
if err != nil {
return fmt.Errorf("could not create vault: %w", err)
}
@ -44,12 +45,15 @@ func WithVault(locations *locations.Locations, panicHandler async.PanicHandler,
"corrupt": corrupt,
}).Debug("Vault created")
cert, _ := encVault.GetBridgeTLSCert()
certs.NewInstaller().LogCertInstallStatus(cert)
// GODT-1950: Add teardown actions (e.g. to close the vault).
return fn(encVault, insecure, corrupt)
}
func newVault(locations *locations.Locations, panicHandler async.PanicHandler) (*vault.Vault, bool, bool, error) {
func newVault(locations *locations.Locations, keychains *keychain.List, panicHandler async.PanicHandler) (*vault.Vault, bool, bool, error) {
vaultDir, err := locations.ProvideSettingsPath()
if err != nil {
return nil, false, false, fmt.Errorf("could not get vault dir: %w", err)
@ -62,7 +66,7 @@ func newVault(locations *locations.Locations, panicHandler async.PanicHandler) (
insecure bool
)
if key, err := loadVaultKey(vaultDir); err != nil {
if key, err := loadVaultKey(vaultDir, keychains); err != nil {
logrus.WithError(err).Error("Could not load/create vault key")
insecure = true
@ -85,13 +89,13 @@ func newVault(locations *locations.Locations, panicHandler async.PanicHandler) (
return vault, insecure, corrupt, nil
}
func loadVaultKey(vaultDir string) ([]byte, error) {
func loadVaultKey(vaultDir string, keychains *keychain.List) ([]byte, error) {
helper, err := vault.GetHelper(vaultDir)
if err != nil {
return nil, fmt.Errorf("could not get keychain helper: %w", err)
}
kc, err := keychain.NewKeychain(helper, constants.KeyChainName)
kc, err := keychain.NewKeychain(helper, constants.KeyChainName, keychains.GetHelpers(), keychains.GetDefaultHelper())
if err != nil {
return nil, fmt.Errorf("could not create keychain: %w", err)
}

View File

@ -45,6 +45,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/telemetry"
"github.com/ProtonMail/proton-bridge/v3/internal/user"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/pkg/keychain"
"github.com/bradenaw/juniper/xslices"
"github.com/go-resty/resty/v2"
"github.com/sirupsen/logrus"
@ -74,7 +75,7 @@ type Bridge struct {
installCh chan installJob
// heartbeat is the telemetry heartbeat for metrics.
heartbeat telemetry.Heartbeat
heartbeat *heartBeatState
// curVersion is the current version of the bridge,
// newVersion is the version that was installed by the updater.
@ -82,6 +83,9 @@ type Bridge struct {
newVersion *semver.Version
newVersionLock safe.RWMutex
// keychains is the utils that own usable keychains found in the OS.
keychains *keychain.List
// focusService is used to raise the bridge window when needed.
focusService *focus.Service
@ -124,9 +128,6 @@ type Bridge struct {
// goUpdate triggers a check/install of updates.
goUpdate func()
// goHeartbeat triggers a check/sending if heartbeat is needed.
goHeartbeat func()
serverManager *imapsmtpserver.Service
syncService *syncservice.Service
}
@ -138,6 +139,7 @@ func New(
autostarter Autostarter, // the autostarter to manage autostart settings
updater Updater, // the updater to fetch and install updates
curVersion *semver.Version, // the current version of the bridge
keychains *keychain.List, // usable keychains
apiURL string, // the URL of the API to use
cookieJar http.CookieJar, // the cookie jar to use
@ -148,6 +150,7 @@ func New(
panicHandler async.PanicHandler,
reporter reporter.Reporter,
uidValidityGenerator imap.UIDValidityGenerator,
heartBeatManager telemetry.HeartbeatManager,
logIMAPClient, logIMAPServer bool, // whether to log IMAP client/server activity
logSMTP bool, // whether to log SMTP activity
@ -163,6 +166,7 @@ func New(
// bridge is the bridge.
bridge, err := newBridge(
context.Background(),
tasks,
imapEventCh,
@ -171,6 +175,7 @@ func New(
autostarter,
updater,
curVersion,
keychains,
panicHandler,
reporter,
@ -178,6 +183,7 @@ func New(
identifier,
proxyCtl,
uidValidityGenerator,
heartBeatManager,
logIMAPClient, logIMAPServer, logSMTP,
)
if err != nil {
@ -196,6 +202,7 @@ func New(
}
func newBridge(
ctx context.Context,
tasks *async.Group,
imapEventCh chan imapEvents.Event,
@ -204,6 +211,7 @@ func newBridge(
autostarter Autostarter,
updater Updater,
curVersion *semver.Version,
keychains *keychain.List,
panicHandler async.PanicHandler,
reporter reporter.Reporter,
@ -211,6 +219,7 @@ func newBridge(
identifier identifier.Identifier,
proxyCtl ProxyController,
uidValidityGenerator imap.UIDValidityGenerator,
heartbeatManager telemetry.HeartbeatManager,
logIMAPClient, logIMAPServer, logSMTP bool,
) (*Bridge, error) {
@ -256,9 +265,13 @@ func newBridge(
newVersion: curVersion,
newVersionLock: safe.NewRWMutex(),
keychains: keychains,
panicHandler: panicHandler,
reporter: reporter,
heartbeat: newHeartBeatState(ctx, panicHandler),
focusService: focusService,
autostarter: autostarter,
locator: locator,
@ -288,6 +301,12 @@ func newBridge(
return nil, err
}
if heartbeatManager == nil {
bridge.heartbeat.init(bridge, bridge)
} else {
bridge.heartbeat.init(bridge, heartbeatManager)
}
bridge.syncService.Run(bridge.tasks)
return bridge, nil
@ -417,6 +436,9 @@ func (bridge *Bridge) GetErrors() []error {
func (bridge *Bridge) Close(ctx context.Context) {
logrus.Info("Closing bridge")
// Stop heart beat before closing users.
bridge.heartbeat.stop()
// Close all users.
safe.Lock(func() {
for _, user := range bridge.users {

View File

@ -49,6 +49,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/user"
"github.com/ProtonMail/proton-bridge/v3/internal/useragent"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/pkg/keychain"
"github.com/ProtonMail/proton-bridge/v3/tests"
"github.com/bradenaw/juniper/xslices"
imapid "github.com/emersion/go-imap-id"
@ -950,6 +951,7 @@ func withBridgeNoMocks(
mocks.Autostarter,
mocks.Updater,
v2_3_0,
keychain.NewTestKeychainsList(),
// The API stuff.
apiURL,
@ -961,6 +963,7 @@ func withBridgeNoMocks(
mocks.CrashHandler,
mocks.Reporter,
testUIDValidityGenerator,
mocks.Heartbeat,
// The logging stuff.
os.Getenv("BRIDGE_LOG_IMAP_CLIENT") == "1",
@ -970,9 +973,6 @@ func withBridgeNoMocks(
require.NoError(t, err)
require.Empty(t, bridge.GetErrors())
// Start the Heartbeat process.
bridge.StartHeartbeat(mocks.Heartbeat)
// Wait for bridge to finish loading users.
waitForEvent(t, eventCh, events.AllUsersLoaded{})

View File

@ -19,6 +19,7 @@ package bridge
import (
"context"
"errors"
"strings"
"github.com/ProtonMail/proton-bridge/v3/internal/clientconfig"
@ -30,8 +31,8 @@ import (
"github.com/sirupsen/logrus"
)
// ConfigureAppleMail configures apple mail for the given userID and address.
// If configuring apple mail for Catalina or newer, it ensures Bridge is using SSL.
// ConfigureAppleMail configures Apple Mail for the given userID and address.
// If configuring Apple Mail for Catalina or newer, it ensures Bridge is using SSL.
func (bridge *Bridge) ConfigureAppleMail(ctx context.Context, userID, address string) error {
logrus.WithFields(logrus.Fields{
"userID": userID,
@ -44,16 +45,28 @@ func (bridge *Bridge) ConfigureAppleMail(ctx context.Context, userID, address st
return ErrNoSuchUser
}
if address == "" {
address = user.Emails()[0]
emails := user.Emails()
displayNames := user.DisplayNames()
if (len(emails) == 0) || (len(displayNames) == 0) {
return errors.New("could not retrieve user address info")
}
username := address
addresses := address
if address == "" {
address = emails[0]
}
var username, displayName, addresses string
if user.GetAddressMode() == vault.CombinedMode {
username = user.Emails()[0]
addresses = strings.Join(user.Emails(), ",")
username = address
displayName = displayNames[username]
addresses = strings.Join(emails, ",")
} else {
username = address
addresses = address
displayName = displayNames[address]
if len(displayName) == 0 {
displayName = address
}
}
if useragent.IsCatalinaOrNewer() && !bridge.vault.GetSMTPSSL() {
@ -69,6 +82,7 @@ func (bridge *Bridge) ConfigureAppleMail(ctx context.Context, userID, address st
bridge.vault.GetIMAPSSL(),
bridge.vault.GetSMTPSSL(),
username,
displayName,
addresses,
user.BridgePass(),
)

View File

@ -20,18 +20,100 @@ package bridge
import (
"context"
"encoding/json"
"sync"
"time"
"github.com/ProtonMail/gluon/async"
"github.com/ProtonMail/gluon/reporter"
"github.com/ProtonMail/proton-bridge/v3/internal/safe"
"github.com/ProtonMail/proton-bridge/v3/internal/telemetry"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/pkg/keychain"
"github.com/sirupsen/logrus"
)
const HeartbeatCheckInterval = time.Hour
type heartBeatState struct {
task *async.Group
telemetry.Heartbeat
taskLock sync.Mutex
taskStarted bool
taskInterval time.Duration
}
func newHeartBeatState(ctx context.Context, panicHandler async.PanicHandler) *heartBeatState {
return &heartBeatState{
task: async.NewGroup(ctx, panicHandler),
}
}
func (h *heartBeatState) init(bridge *Bridge, manager telemetry.HeartbeatManager) {
h.Heartbeat = telemetry.NewHeartbeat(manager, 1143, 1025, bridge.GetGluonCacheDir(), bridge.keychains.GetDefaultHelper())
h.taskInterval = manager.GetHeartbeatPeriodicInterval()
h.SetRollout(bridge.GetUpdateRollout())
h.SetAutoStart(bridge.GetAutostart())
h.SetAutoUpdate(bridge.GetAutoUpdate())
h.SetBeta(bridge.GetUpdateChannel())
h.SetDoh(bridge.GetProxyAllowed())
h.SetShowAllMail(bridge.GetShowAllMail())
h.SetIMAPConnectionMode(bridge.GetIMAPSSL())
h.SetSMTPConnectionMode(bridge.GetSMTPSSL())
h.SetIMAPPort(bridge.GetIMAPPort())
h.SetSMTPPort(bridge.GetSMTPPort())
h.SetCacheLocation(bridge.GetGluonCacheDir())
if val, err := bridge.GetKeychainApp(); err != nil {
h.SetKeyChainPref(val)
} else {
h.SetKeyChainPref(bridge.keychains.GetDefaultHelper())
}
h.SetPrevVersion(bridge.GetLastVersion().String())
safe.RLock(func() {
var splitMode = false
for _, user := range bridge.users {
if user.GetAddressMode() == vault.SplitMode {
splitMode = true
break
}
}
var nbAccount = len(bridge.users)
h.SetNbAccount(nbAccount)
h.SetSplitMode(splitMode)
// Do not try to send if there is no user yet.
if nbAccount > 0 {
defer h.start()
}
}, bridge.usersLock)
}
func (h *heartBeatState) start() {
h.taskLock.Lock()
defer h.taskLock.Unlock()
if h.taskStarted {
return
}
h.taskStarted = true
h.task.PeriodicOrTrigger(h.taskInterval, 0, func(ctx context.Context) {
logrus.Debug("Checking for heartbeat")
h.TrySending(ctx)
})
}
func (h *heartBeatState) stop() {
h.taskLock.Lock()
defer h.taskLock.Unlock()
if !h.taskStarted {
return
}
h.task.CancelAndWait()
h.taskStarted = false
}
func (bridge *Bridge) IsTelemetryAvailable(ctx context.Context) bool {
var flag = true
if bridge.GetTelemetryDisabled() {
@ -80,49 +162,6 @@ func (bridge *Bridge) SetLastHeartbeatSent(timestamp time.Time) error {
return bridge.vault.SetLastHeartbeatSent(timestamp)
}
func (bridge *Bridge) StartHeartbeat(manager telemetry.HeartbeatManager) {
bridge.heartbeat = telemetry.NewHeartbeat(manager, 1143, 1025, bridge.GetGluonCacheDir(), keychain.DefaultHelper)
// Check for heartbeat when triggered.
bridge.goHeartbeat = bridge.tasks.PeriodicOrTrigger(HeartbeatCheckInterval, 0, func(ctx context.Context) {
logrus.Debug("Checking for heartbeat")
bridge.heartbeat.TrySending(ctx)
})
bridge.heartbeat.SetRollout(bridge.GetUpdateRollout())
bridge.heartbeat.SetAutoStart(bridge.GetAutostart())
bridge.heartbeat.SetAutoUpdate(bridge.GetAutoUpdate())
bridge.heartbeat.SetBeta(bridge.GetUpdateChannel())
bridge.heartbeat.SetDoh(bridge.GetProxyAllowed())
bridge.heartbeat.SetShowAllMail(bridge.GetShowAllMail())
bridge.heartbeat.SetIMAPConnectionMode(bridge.GetIMAPSSL())
bridge.heartbeat.SetSMTPConnectionMode(bridge.GetSMTPSSL())
bridge.heartbeat.SetIMAPPort(bridge.GetIMAPPort())
bridge.heartbeat.SetSMTPPort(bridge.GetSMTPPort())
bridge.heartbeat.SetCacheLocation(bridge.GetGluonCacheDir())
if val, err := bridge.GetKeychainApp(); err != nil {
bridge.heartbeat.SetKeyChainPref(val)
} else {
bridge.heartbeat.SetKeyChainPref(keychain.DefaultHelper)
}
bridge.heartbeat.SetPrevVersion(bridge.GetLastVersion().String())
safe.RLock(func() {
var splitMode = false
for _, user := range bridge.users {
if user.GetAddressMode() == vault.SplitMode {
splitMode = true
break
}
}
var nbAccount = len(bridge.users)
bridge.heartbeat.SetNbAccount(nbAccount)
bridge.heartbeat.SetSplitMode(splitMode)
// Do not try to send if there is no user yet.
if nbAccount > 0 {
defer bridge.goHeartbeat()
}
}, bridge.usersLock)
func (bridge *Bridge) GetHeartbeatPeriodicInterval() time.Duration {
return HeartbeatCheckInterval
}

View File

@ -0,0 +1,24 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package bridge
import "golang.org/x/exp/maps"
func (bridge *Bridge) GetHelpersNames() []string {
return maps.Keys(bridge.keychains.GetHelpers())
}

View File

@ -7,6 +7,7 @@ import (
"os"
"sync"
"testing"
"time"
"github.com/Masterminds/semver/v3"
"github.com/ProtonMail/proton-bridge/v3/internal/bridge/mocks"
@ -51,6 +52,7 @@ func NewMocks(tb testing.TB, version, minAuto *semver.Version) *Mocks {
// this is called at start of heartbeat process.
mocks.Heartbeat.EXPECT().IsTelemetryAvailable(gomock.Any()).AnyTimes()
mocks.Heartbeat.EXPECT().GetHeartbeatPeriodicInterval().AnyTimes().Return(500 * time.Millisecond)
return mocks
}
@ -154,3 +156,7 @@ func (testUpdater *TestUpdater) GetVersionInfo(_ context.Context, _ updater.Down
func (testUpdater *TestUpdater) InstallUpdate(_ context.Context, _ updater.Downloader, _ updater.VersionInfo) error {
return nil
}
func (testUpdater *TestUpdater) RemoveOldUpdates() error {
return nil
}

View File

@ -36,6 +36,20 @@ func (m *MockHeartbeatManager) EXPECT() *MockHeartbeatManagerMockRecorder {
return m.recorder
}
// GetHeartbeatPeriodicInterval mocks base method.
func (m *MockHeartbeatManager) GetHeartbeatPeriodicInterval() time.Duration {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetHeartbeatPeriodicInterval")
ret0, _ := ret[0].(time.Duration)
return ret0
}
// GetHeartbeatPeriodicInterval indicates an expected call of GetHeartbeatPeriodicInterval.
func (mr *MockHeartbeatManagerMockRecorder) GetHeartbeatPeriodicInterval() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHeartbeatPeriodicInterval", reflect.TypeOf((*MockHeartbeatManager)(nil).GetHeartbeatPeriodicInterval))
}
// GetLastHeartbeatSent mocks base method.
func (m *MockHeartbeatManager) GetLastHeartbeatSent() time.Time {
m.ctrl.T.Helper()

View File

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

View File

@ -261,9 +261,12 @@ func (bridge *Bridge) SetTelemetryDisabled(isDisabled bool) error {
return err
}
// If telemetry is re-enabled locally, try to send the heartbeat.
if !isDisabled {
defer bridge.goHeartbeat()
if isDisabled {
bridge.heartbeat.stop()
} else {
bridge.heartbeat.start()
}
return nil
}

View File

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

View File

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

View File

@ -494,7 +494,7 @@ func (bridge *Bridge) addUser(
return fmt.Errorf("failed to add vault user: %w", err)
}
if err := bridge.addUserWithVault(ctx, client, apiUser, vaultUser); err != nil {
if err := bridge.addUserWithVault(ctx, client, apiUser, vaultUser, isNew); err != nil {
if _, ok := err.(*resty.ResponseError); ok || isLogin {
logrus.WithError(err).Error("Failed to add user, clearing its secrets from vault")
@ -529,6 +529,7 @@ func (bridge *Bridge) addUserWithVault(
client *proton.Client,
apiUser proton.User,
vault *vault.User,
isNew bool,
) error {
statsPath, err := bridge.locator.ProvideStatsPath()
if err != nil {
@ -556,6 +557,7 @@ func (bridge *Bridge) addUserWithVault(
&bridgeEventSubscription{b: bridge},
bridge.syncService,
syncSettingsPath,
isNew,
)
if err != nil {
return fmt.Errorf("failed to create user: %w", err)
@ -592,7 +594,7 @@ func (bridge *Bridge) addUserWithVault(
}, bridge.usersLock)
// As we need at least one user to send heartbeat, try to send it.
defer bridge.goHeartbeat()
bridge.heartbeat.start()
return nil
}

View File

@ -356,6 +356,10 @@ func removeCertTrustCGo(buffer *C.char, size C.ulonglong) error {
}
}
func osSupportCertInstall() bool {
return true
}
// installCert installs a certificate in the keychain. The certificate is added to the keychain and it is set as trusted.
// This function will trigger a security prompt from the system, unless the certificate is already trusted in the user keychain.
func installCert(certPEM []byte) error {

View File

@ -28,6 +28,7 @@ import (
func TestCertInKeychain(t *testing.T) {
// no trust settings change is performed, so this test will not trigger an OS security prompt.
certPEM := generatePEMCertificate(t)
require.True(t, osSupportCertInstall())
require.False(t, isCertInKeychain(certPEM))
require.NoError(t, addCertToKeychain(certPEM))
require.True(t, isCertInKeychain(certPEM))

View File

@ -17,6 +17,10 @@
package certs
func osSupportCertInstall() bool {
return false
}
func installCert([]byte) error {
return nil // Linux doesn't have a root cert store.
}

View File

@ -17,6 +17,10 @@
package certs
func osSupportCertInstall() bool {
return false
}
func installCert([]byte) error {
return nil // NOTE(GODT-986): Install certs to root cert store?
}

View File

@ -37,6 +37,10 @@ func NewInstaller() *Installer {
}
}
func (installer *Installer) OSSupportCertInstall() bool {
return osSupportCertInstall()
}
func (installer *Installer) InstallCert(certPEM []byte) error {
installer.log.Info("Installing the Bridge TLS certificate in the OS keychain")
@ -64,3 +68,15 @@ func (installer *Installer) UninstallCert(certPEM []byte) error {
func (installer *Installer) IsCertInstalled(certPEM []byte) bool {
return isCertInstalled(certPEM)
}
// LogCertInstallStatus reports the current status of the certificate installation in the log.
// If certificate installation is not supported on the platform, this function does nothing.
func (installer *Installer) LogCertInstallStatus(certPEM []byte) {
if installer.OSSupportCertInstall() {
if installer.IsCertInstalled(certPEM) {
installer.log.Info("The Bridge TLS certificate is installed in the OS keychain")
} else {
installer.log.Info("The Bridge TLS certificate is not installed in the OS keychain")
}
}
}

View File

@ -39,10 +39,10 @@ func (c *AppleMail) Configure(
hostname string,
imapPort, smtpPort int,
imapSSL, smtpSSL bool,
username, addresses string,
username, displayName, addresses string,
password []byte,
) error {
mc := prepareMobileConfig(hostname, imapPort, smtpPort, imapSSL, smtpSSL, username, addresses, password)
mc := prepareMobileConfig(hostname, imapPort, smtpPort, imapSSL, smtpSSL, username, displayName, addresses, password)
confPath, err := saveConfigTemporarily(mc)
if err != nil {
@ -66,13 +66,13 @@ func prepareMobileConfig(
hostname string,
imapPort, smtpPort int,
imapSSL, smtpSSL bool,
username, addresses string,
username, displayName, addresses string,
password []byte,
) *mobileconfig.Config {
return &mobileconfig.Config{
DisplayName: username,
EmailAddress: addresses,
AccountName: username,
AccountName: displayName,
AccountDescription: username,
Identifier: "protonmail " + username + strconv.FormatInt(time.Now().Unix(), 10),
IMAP: &mobileconfig.IMAP{

View File

@ -415,7 +415,11 @@ int main(int argc, char *argv[]) {
}
catch (Exception const &e) {
sentry_uuid_s const uuid = reportSentryException("Exception occurred during main", e);
QMessageBox::critical(nullptr, "Error", e.qwhat());
QString message = e.qwhat();
if (e.showSupportLink()) {
message += R"(<br/><br/>If the issue persists, please contact our <a href="https://proton.me/support/contact">customer support</a>.)";
}
QMessageBox::critical(nullptr, "Error", message);
QTextStream(stderr) << "reportID: " << QByteArray(uuid.bytes, 16).toHex() << " Captured exception :" << e.detailedWhat() << "\n";
return EXIT_FAILURE;
}

View File

@ -69,8 +69,8 @@ ApplicationWindow {
}
colorScheme: ProtonStyle.currentStyle
height: ProtonStyle.window_default_height
minimumHeight:ProtonStyle.window_minimum_height
height: screen.height < ProtonStyle.window_default_height + 100 ? ProtonStyle.window_minimum_height : ProtonStyle.window_default_height
minimumHeight: ProtonStyle.window_minimum_height
minimumWidth: ProtonStyle.window_minimum_width
visible: true
width: ProtonStyle.window_default_width

View File

@ -380,7 +380,7 @@ QtObject {
}
property Notification diskFull: Notification {
brief: title
description: qsTr("Quit Bridge and free disk space or disable the local cache (not recommended).")
description: qsTr("Quit Bridge and free disk space or move the local cache to another disk.")
group: Notifications.Group.Configuration | Notifications.Group.Dialogs
icon: "./icons/ic-exclamation-circle-filled.svg"
title: qsTr("Your disk is almost full")

View File

@ -26,14 +26,16 @@ namespace bridgepp {
/// \param[in] what A description of the exception.
/// \param[in] details The optional details for the exception.
/// \param[in] function The name of the calling function.
/// \param[in] showSupportLink Should a link to the support web form be included in GUI message.
//****************************************************************************************************************************************************
Exception::Exception(QString qwhat, QString details, QString function, QByteArray attachment) noexcept
Exception::Exception(QString qwhat, QString details, QString function, QByteArray attachment, bool showSupportLink) noexcept
: std::exception()
, qwhat_(std::move(qwhat))
, what_(qwhat_.toLocal8Bit())
, details_(std::move(details))
, function_(std::move(function))
, attachment_(std::move(attachment)) {
, attachment_(std::move(attachment))
, showSupportLink_(showSupportLink) {
}
@ -46,7 +48,8 @@ Exception::Exception(Exception const &ref) noexcept
, what_(ref.what_)
, details_(ref.details_)
, function_(ref.function_)
, attachment_(ref.attachment_) {
, attachment_(ref.attachment_)
, showSupportLink_(ref.showSupportLink_) {
}
@ -59,7 +62,8 @@ Exception::Exception(Exception &&ref) noexcept
, what_(ref.what_)
, details_(ref.details_)
, function_(ref.function_)
, attachment_(ref.attachment_) {
, attachment_(ref.attachment_)
, showSupportLink_(ref.showSupportLink_) {
}
@ -118,4 +122,12 @@ QString Exception::detailedWhat() const {
}
//****************************************************************************************************************************************************
/// \return true iff A link to the support page should shown in the GUI message box.
//****************************************************************************************************************************************************
bool Exception::showSupportLink() const {
return showSupportLink_;
}
} // namespace bridgepp

View File

@ -33,7 +33,7 @@ namespace bridgepp {
class Exception : public std::exception {
public: // member functions
explicit Exception(QString qwhat = QString(), QString details = QString(), QString function = QString(),
QByteArray attachment = QByteArray()) noexcept; ///< Constructor
QByteArray attachment = QByteArray(), bool showSupportLink = false) noexcept; ///< Constructor
Exception(Exception const &ref) noexcept; ///< copy constructor
Exception(Exception &&ref) noexcept; ///< copy constructor
Exception &operator=(Exception const &) = delete; ///< Disabled assignment operator
@ -45,6 +45,7 @@ public: // member functions
QString function() const noexcept; ///< Return the function that threw the exception.
QByteArray attachment() const noexcept; ///< Return the attachment for the exception.
QString detailedWhat() const; ///< Return the detailed description of the message (i.e. including the function name and the details).
bool showSupportLink() const; ///< Return the value for the 'Show support link' option.
public: // static data members
static qsizetype const attachmentMaxLength {25 * 1024}; ///< The maximum length text attachment sent in Sentry reports, in bytes.
@ -55,6 +56,7 @@ private: // data members
QString const details_; ///< The optional details for the exception.
QString const function_; ///< The name of the function that created the exception.
QByteArray const attachment_; ///< The attachment to add to the exception.
bool const showSupportLink_; ///< Should the GUI feedback include a link to support.
};

View File

@ -72,8 +72,8 @@ GRPCConfig GRPCClient::waitAndRetrieveServiceConfig(QString const & sessionID, Q
bool found = false;
while (true) {
if (serverProcess && serverProcess->getStatus().ended) {
throw Exception("Bridge application exited before providing a gRPC service configuration file.", QString(), __FUNCTION__,
tailOfLatestBridgeLog(sessionID));
throw Exception("Bridge failed to start.", "Bridge application exited before providing a gRPC service configuration file", __FUNCTION__,
tailOfLatestBridgeLog(sessionID), true);
}
if (file.exists()) {

View File

@ -33,10 +33,8 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/safe"
"github.com/ProtonMail/proton-bridge/v3/internal/service"
"github.com/ProtonMail/proton-bridge/v3/internal/updater"
"github.com/ProtonMail/proton-bridge/v3/pkg/keychain"
"github.com/ProtonMail/proton-bridge/v3/pkg/ports"
"github.com/sirupsen/logrus"
"golang.org/x/exp/maps"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/runtime/protoimpl"
@ -712,7 +710,7 @@ func (s *Service) IsPortFree(_ context.Context, port *wrapperspb.Int32Value) (*w
func (s *Service) AvailableKeychains(_ context.Context, _ *emptypb.Empty) (*AvailableKeychainsResponse, error) {
s.log.Debug("AvailableKeychains")
return &AvailableKeychainsResponse{Keychains: maps.Keys(keychain.Helpers)}, nil
return &AvailableKeychainsResponse{Keychains: s.bridge.GetHelpersNames()}, nil
}
func (s *Service) SetCurrentKeychain(ctx context.Context, keychain *wrapperspb.StringValue) (*emptypb.Empty, error) {

View File

@ -48,4 +48,6 @@ type APIClient interface {
DeleteMessage(ctx context.Context, messageIDs ...string) error
MarkMessagesRead(ctx context.Context, messageIDs ...string) error
MarkMessagesUnread(ctx context.Context, messageIDs ...string) error
MarkMessagesForwarded(ctx context.Context, messageIDs ...string) error
MarkMessagesUnForwarded(ctx context.Context, messageIDs ...string) error
}

View File

@ -84,9 +84,9 @@ func NewConnector(
identityState: identityState,
addrID: addrID,
showAllMail: b32(showAllMail),
flags: defaultFlags,
permFlags: defaultPermanentFlags,
attrs: defaultAttributes,
flags: defaultMailboxFlags(),
permFlags: defaultMailboxPermanentFlags(),
attrs: defaultMailboxAttributes(),
client: apiClient,
telemetry: telemetry,
@ -144,6 +144,18 @@ func (s *Connector) Init(ctx context.Context, cache connector.IMAPState) error {
}
}
}
// Retroactively apply the forwarded flags to existing mailboxes so that the IMAP clients can recognize
// that they can store these flags now.
if err := write.AddFlagsToAllMailboxes(ctx, imap.ForwardFlagList...); err != nil {
return fmt.Errorf("failed to add \\Forward flag to all mailboxes:%w", err)
}
// Add forwarded flag as perm flags to all mailboxes.
if err := write.AddPermFlagsToAllMailboxes(ctx, imap.ForwardFlagList...); err != nil {
return fmt.Errorf("failed to add \\Forward permanent flag to all mailboxes:%w", err)
}
return nil
})
}
@ -487,6 +499,14 @@ func (s *Connector) MarkMessagesFlagged(ctx context.Context, _ connector.IMAPSta
return s.client.UnlabelMessages(ctx, usertypes.MapTo[imap.MessageID, string](messageIDs), proton.StarredLabel)
}
func (s *Connector) MarkMessagesForwarded(ctx context.Context, _ connector.IMAPStateWrite, messageIDs []imap.MessageID, flagged bool) error {
if flagged {
return s.client.MarkMessagesForwarded(ctx, usertypes.MapTo[imap.MessageID, string](messageIDs)...)
}
return s.client.MarkMessagesUnForwarded(ctx, usertypes.MapTo[imap.MessageID, string](messageIDs)...)
}
func (s *Connector) GetUpdates() <-chan imap.Update {
return s.updateCh.GetChannel()
}
@ -501,12 +521,6 @@ func (s *Connector) ShowAllMail(v bool) {
atomic.StoreUint32(&s.showAllMail, b32(v))
}
var (
defaultFlags = imap.NewFlagSet(imap.FlagSeen, imap.FlagFlagged, imap.FlagDeleted) // nolint:gochecknoglobals
defaultPermanentFlags = imap.NewFlagSet(imap.FlagSeen, imap.FlagFlagged, imap.FlagDeleted) // nolint:gochecknoglobals
defaultAttributes = imap.NewFlagSet() // nolint:gochecknoglobals
)
const (
folderPrefix = "Folders"
labelPrefix = "Labels"
@ -812,3 +826,18 @@ func fixGODT3003Labels(
return applied, nil
}
func defaultMailboxFlags() imap.FlagSet {
f := imap.NewFlagSet(imap.FlagSeen, imap.FlagFlagged, imap.FlagDeleted)
f.AddToSelf(imap.ForwardFlagList...)
return f
}
func defaultMailboxPermanentFlags() imap.FlagSet {
return defaultMailboxFlags()
}
func defaultMailboxAttributes() imap.FlagSet {
return imap.NewFlagSet()
}

View File

@ -68,6 +68,10 @@ func BuildFlagSetFromMessageMetadata(message proton.MessageMetadata) imap.FlagSe
flags.AddToSelf(imap.FlagAnswered)
}
if message.IsForwarded {
flags.AddToSelf(imap.ForwardFlagList...)
}
return flags
}

View File

@ -32,8 +32,8 @@ func newSystemMailboxCreatedUpdate(labelID imap.MailboxID, labelName string) *im
}
attrs := imap.NewFlagSet(imap.AttrNoInferiors)
permanentFlags := defaultPermanentFlags
flags := defaultFlags
permanentFlags := defaultMailboxPermanentFlags()
flags := defaultMailboxFlags()
switch labelID {
case proton.TrashLabel:
@ -86,8 +86,8 @@ func newPlaceHolderMailboxCreatedUpdate(labelName string) *imap.MailboxCreated {
return imap.NewMailboxCreated(imap.Mailbox{
ID: imap.MailboxID(labelName),
Name: []string{labelName},
Flags: defaultFlags,
PermanentFlags: defaultPermanentFlags,
Flags: defaultMailboxFlags(),
PermanentFlags: defaultMailboxPermanentFlags(),
Attributes: imap.NewFlagSet(imap.AttrNoSelect),
})
}
@ -96,8 +96,8 @@ func newMailboxCreatedUpdate(labelID imap.MailboxID, labelName []string) *imap.M
return imap.NewMailboxCreated(imap.Mailbox{
ID: labelID,
Name: labelName,
Flags: defaultFlags,
PermanentFlags: defaultPermanentFlags,
Flags: defaultMailboxFlags(),
PermanentFlags: defaultMailboxPermanentFlags(),
Attributes: imap.NewFlagSet(),
})
}

View File

@ -35,6 +35,44 @@ func (m *MockIMAPStateWrite) EXPECT() *MockIMAPStateWriteMockRecorder {
return m.recorder
}
// AddFlagsToAllMailboxes mocks base method.
func (m *MockIMAPStateWrite) AddFlagsToAllMailboxes(arg0 context.Context, arg1 ...string) error {
m.ctrl.T.Helper()
varargs := []interface{}{arg0}
for _, a := range arg1 {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "AddFlagsToAllMailboxes", varargs...)
ret0, _ := ret[0].(error)
return ret0
}
// AddFlagsToAllMailboxes indicates an expected call of AddFlagsToAllMailboxes.
func (mr *MockIMAPStateWriteMockRecorder) AddFlagsToAllMailboxes(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]interface{}{arg0}, arg1...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddFlagsToAllMailboxes", reflect.TypeOf((*MockIMAPStateWrite)(nil).AddFlagsToAllMailboxes), varargs...)
}
// AddPermFlagsToAllMailboxes mocks base method.
func (m *MockIMAPStateWrite) AddPermFlagsToAllMailboxes(arg0 context.Context, arg1 ...string) error {
m.ctrl.T.Helper()
varargs := []interface{}{arg0}
for _, a := range arg1 {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "AddPermFlagsToAllMailboxes", varargs...)
ret0, _ := ret[0].(error)
return ret0
}
// AddPermFlagsToAllMailboxes indicates an expected call of AddPermFlagsToAllMailboxes.
func (mr *MockIMAPStateWriteMockRecorder) AddPermFlagsToAllMailboxes(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]interface{}{arg0}, arg1...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddPermFlagsToAllMailboxes", reflect.TypeOf((*MockIMAPStateWrite)(nil).AddPermFlagsToAllMailboxes), varargs...)
}
// CreateMailbox mocks base method.
func (m *MockIMAPStateWrite) CreateMailbox(arg0 context.Context, arg1 imap.Mailbox) error {
m.ctrl.T.Helper()

View File

@ -395,6 +395,11 @@ func (s *Service) run(ctx context.Context) { //nolint gocyclo
// event service is unable to reply to the request until the events have been processed.
s.log.Info("Sync complete, starting API event stream")
go func() {
// If context cancelled do not do anything
if ctx.Err() != nil {
return
}
if err := s.eventProvider.RewindEventID(ctx, s.lastHandledEventID); err != nil {
if errors.Is(err, context.Canceled) {
return

View File

@ -21,16 +21,21 @@ import (
"context"
"io"
"sync"
"time"
)
type Accounts struct {
accountsLock sync.RWMutex
accounts map[string]*Service
accounts map[string]*smtpAccountState
}
const maxFailedCommands = 3
const defaultErrTimeout = 20 * time.Second
const successiveErrInterval = time.Second
func NewAccounts() *Accounts {
return &Accounts{
accounts: make(map[string]*Service),
accounts: make(map[string]*smtpAccountState),
}
}
@ -38,7 +43,10 @@ func (s *Accounts) AddAccount(account *Service) {
s.accountsLock.Lock()
defer s.accountsLock.Unlock()
s.accounts[account.UserID()] = account
s.accounts[account.UserID()] = &smtpAccountState{
service: account,
errTimeout: defaultErrTimeout,
}
}
func (s *Accounts) RemoveAccount(account *Service) {
@ -52,18 +60,18 @@ func (s *Accounts) CheckAuth(user string, password []byte) (string, string, erro
s.accountsLock.RLock()
defer s.accountsLock.RUnlock()
for id, service := range s.accounts {
addrID, err := service.checkAuth(context.Background(), user, password)
for id, account := range s.accounts {
addrID, err := account.service.checkAuth(context.Background(), user, password)
if err != nil {
continue
}
service.telemetry.ReportSMTPAuthSuccess(context.Background())
account.service.telemetry.ReportSMTPAuthSuccess(context.Background())
return id, addrID, nil
}
for _, service := range s.accounts {
service.telemetry.ReportSMTPAuthFailed(user)
service.service.telemetry.ReportSMTPAuthFailed(user)
}
return "", "", ErrNoSuchUser
@ -77,10 +85,57 @@ func (s *Accounts) SendMail(ctx context.Context, userID, addrID, from string, to
s.accountsLock.RLock()
defer s.accountsLock.RUnlock()
service, ok := s.accounts[userID]
requestTime := time.Now()
account, ok := s.accounts[userID]
if !ok {
return ErrNoSuchUser
}
return service.SendMail(ctx, addrID, from, to, r)
if err := account.canMakeRequest(requestTime); err != nil {
return err
}
err := account.service.SendMail(ctx, addrID, from, to, r)
account.handleSMTPErr(requestTime, err)
return err
}
type smtpAccountState struct {
service *Service
errTimeout time.Duration
errLock sync.Mutex
errCounter int
lastRequest time.Time
}
func (s *smtpAccountState) canMakeRequest(requestTime time.Time) error {
s.errLock.Lock()
defer s.errLock.Unlock()
if s.errCounter >= maxFailedCommands {
if requestTime.Sub(s.lastRequest) >= s.errTimeout {
s.errCounter = 0
return nil
}
return ErrTooManyErrors
}
return nil
}
func (s *smtpAccountState) handleSMTPErr(requestTime time.Time, err error) {
s.errLock.Lock()
defer s.errLock.Unlock()
if err == nil || requestTime.Sub(s.lastRequest) > successiveErrInterval {
s.errCounter = 0
} else {
s.errCounter++
}
s.lastRequest = requestTime
}

View File

@ -0,0 +1,46 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package smtp
import (
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestAccountTimeout(t *testing.T) {
account := smtpAccountState{errTimeout: 5 * time.Second}
err := errors.New("fail")
for i := 0; i <= maxFailedCommands; i++ {
requestTime := time.Now()
assert.Nil(t, account.canMakeRequest(requestTime))
account.handleSMTPErr(requestTime, err)
}
{
requestTime := time.Now()
assert.ErrorIs(t, account.canMakeRequest(requestTime), ErrTooManyErrors)
}
assert.Eventually(t, func() bool {
requestTime := time.Now()
return account.canMakeRequest(requestTime) == nil
}, 10*time.Second, time.Second)
}

View File

@ -17,8 +17,24 @@
package smtp
import "errors"
import (
"errors"
"fmt"
)
var ErrInvalidRecipient = errors.New("invalid recipient")
var ErrInvalidReturnPath = errors.New("invalid return path")
var ErrNoSuchUser = errors.New("no such user")
var ErrTooManyErrors = errors.New("too many failed requests, please try again later")
type ErrCanNotSendOnAddress struct {
address string
}
func NewErrCanNotSendOnAddress(address string) *ErrCanNotSendOnAddress {
return &ErrCanNotSendOnAddress{address: address}
}
func (e ErrCanNotSendOnAddress) Error() string {
return fmt.Sprintf("can't send on address: %v", e.address)
}

View File

@ -95,6 +95,16 @@ func (s *Service) smtpSendMail(ctx context.Context, authID string, from string,
// If the message contains a sender, use it instead of the one from the return path.
if sender, ok := getMessageSender(parser); ok {
from = sender
fromAddr, err = s.identityState.GetAddr(from)
if err != nil {
logrus.WithError(err).Errorf("Failed to get identity for from address %v", sender)
return ErrInvalidReturnPath
}
}
if !fromAddr.Send {
s.log.Errorf("Can't send emails on address: %v", fromAddr.Email)
return &ErrCanNotSendOnAddress{address: fromAddr.Email}
}
// Load the user's mail settings.
@ -181,7 +191,7 @@ func (s *Service) sendWithKey(
if message.InReplyTo != "" {
references = append(references, message.InReplyTo)
}
parentID, err := getParentID(ctx, s.client, authAddrID, addrMode, references)
parentID, draftsToDelete, err := getParentID(ctx, s.client, authAddrID, addrMode, references)
if err != nil {
if err := s.reporter.ReportMessageWithContext("Failed to get parent ID", reporter.Context{
"error": err,
@ -207,7 +217,7 @@ func (s *Service) sendWithKey(
return proton.Message{}, fmt.Errorf("unsupported MIME type: %v", message.MIMEType)
}
draft, err := s.createDraft(ctx, addrKR, emails, from, to, parentID, message.InReplyTo, proton.DraftTemplate{
draft, err := s.createDraft(ctx, addrKR, emails, from, to, parentID, message.InReplyTo, message.XForward, proton.DraftTemplate{
Subject: message.Subject,
Body: decBody,
MIMEType: message.MIMEType,
@ -243,6 +253,13 @@ func (s *Service) sendWithKey(
return proton.Message{}, fmt.Errorf("failed to send draft: %w", err)
}
// Only delete the drafts, if any, after message was successfully sent.
if len(draftsToDelete) != 0 {
if err := s.client.DeleteMessage(ctx, draftsToDelete...); err != nil {
s.log.WithField("ids", draftsToDelete).WithError(err).Errorf("Failed to delete requested messages from Drafts")
}
}
return res, nil
}
@ -252,11 +269,12 @@ func getParentID(
authAddrID string,
addrMode usertypes.AddressMode,
references []string,
) (string, error) {
) (string, []string, error) {
var (
parentID string
internal []string
external []string
parentID string
internal []string
external []string
draftsToDelete []string
)
// Collect all the internal and external references of the message.
@ -281,14 +299,18 @@ func getParentID(
AddressID: addrID,
})
if err != nil {
return "", fmt.Errorf("failed to get message metadata: %w", err)
return "", nil, fmt.Errorf("failed to get message metadata: %w", err)
}
for _, metadata := range metadata {
if !metadata.IsDraft() {
parentID = metadata.ID
} else if err := client.DeleteMessage(ctx, metadata.ID); err != nil {
return "", fmt.Errorf("failed to delete message: %w", err)
} else {
// We need to record this ID to delete later after the message has been sent successfully. This is
// required for Apple Mail to correctly delete a draft when a draft is created in Apple Mail, then
// edited on the web, edited again in Apple Mail and then Send from Apple Mail. If we don't
// delete the referenced draft it is never deleted from the drafts folder.
draftsToDelete = append(draftsToDelete, metadata.ID)
}
}
}
@ -309,7 +331,7 @@ func getParentID(
AddressID: addrID,
})
if err != nil {
return "", fmt.Errorf("failed to get message metadata: %w", err)
return "", nil, fmt.Errorf("failed to get message metadata: %w", err)
}
switch len(metadata) {
@ -334,7 +356,7 @@ func getParentID(
}
}
return parentID, nil
return parentID, draftsToDelete, nil
}
func (s *Service) createDraft(
@ -345,6 +367,7 @@ func (s *Service) createDraft(
to []string,
parentID string,
replyToID string,
xForwardID string,
template proton.DraftTemplate,
) (proton.Message, error) {
// Check sender: set the sender if it's missing.
@ -380,7 +403,12 @@ func (s *Service) createDraft(
var action proton.CreateDraftAction
if len(replyToID) > 0 {
action = proton.ReplyAction
// Thunderbird fills both ReplyTo and adds an X-Forwarded-Message-Id header when forwarding.
if replyToID == xForwardID {
action = proton.ForwardAction
} else {
action = proton.ReplyAction
}
} else {
action = proton.ForwardAction
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -24,6 +24,7 @@ import (
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
const (
@ -33,13 +34,14 @@ const (
)
type contactSettings struct {
Email string
Keys []string
Scheme string
Sign bool
SignIsSet bool
Encrypt bool
MIMEType rfc822.MIMEType
Email string
Keys []string
Scheme string
Sign bool
SignIsSet bool
Encrypt bool
EncryptUntrusted bool
MIMEType rfc822.MIMEType
}
// newContactSettings converts the API settings into our local settings.
@ -60,6 +62,12 @@ func newContactSettings(settings proton.ContactSettings) *contactSettings {
metadata.Encrypt = *settings.Encrypt
}
if settings.EncryptUntrusted != nil {
metadata.EncryptUntrusted = *settings.EncryptUntrusted
} else {
metadata.EncryptUntrusted = true
}
if settings.Scheme != nil {
switch *settings.Scheme { // nolint:exhaustive
case proton.PGPMIMEScheme:
@ -425,9 +433,12 @@ func (b *sendPrefsBuilder) setExternalPGPSettingsWithWKDKeys(
return errors.New("an API key is necessary but wasn't provided")
}
// We always encrypt and sign external mail if WKD keys are present.
b.withEncrypt(true)
b.withSign(true)
b.withEncrypt(vCardData.EncryptUntrusted)
if vCardData.EncryptUntrusted {
b.withSign(true)
} else if vCardData.SignIsSet {
b.withSign(vCardData.Sign)
}
// If the contact has a specific Scheme preference, we set it (otherwise we
// leave it unset to allow it to be filled in with the default value later).
@ -547,6 +558,7 @@ func (b *sendPrefsBuilder) setEncryptionPreferences(mailSettings proton.MailSett
// Otherwise keep the defined value.
switch mailSettings.PGPScheme {
case proton.PGPInlineScheme:
logrus.WithFields(logrus.Fields{"service": "smtp", "settings": "account"}).Warn("PGPInline scheme used. Planed to be deprecated.")
b.withSchemeDefault(pgpInline)
case proton.PGPMIMEScheme:
b.withSchemeDefault(pgpMIME)

View File

@ -110,7 +110,22 @@ func TestPreferencesBuilder(t *testing.T) {
{
name: "wkd-external",
contactMeta: &contactSettings{},
contactMeta: &contactSettings{EncryptUntrusted: true},
receivedKeys: []proton.PublicKey{{PublicKey: testPublicKey}},
isInternal: false,
mailSettings: proton.MailSettings{PGPScheme: proton.PGPMIMEScheme, DraftMIMEType: "text/html"},
wantEncrypt: true,
wantSign: proton.DetachedSignature,
wantScheme: proton.PGPMIMEScheme,
wantMIMEType: "multipart/mixed",
wantPublicKey: testPublicKey,
},
{
name: "wkd-external",
contactMeta: &contactSettings{EncryptUntrusted: true},
receivedKeys: []proton.PublicKey{{PublicKey: testPublicKey}},
isInternal: false,
mailSettings: proton.MailSettings{PGPScheme: proton.PGPMIMEScheme, DraftMIMEType: "text/html"},
@ -125,7 +140,7 @@ func TestPreferencesBuilder(t *testing.T) {
{
name: "wkd-external with contact-specific email format",
contactMeta: &contactSettings{MIMEType: "text/plain"},
contactMeta: &contactSettings{MIMEType: "text/plain", EncryptUntrusted: true},
receivedKeys: []proton.PublicKey{{PublicKey: testPublicKey}},
isInternal: false,
mailSettings: proton.MailSettings{PGPScheme: proton.PGPMIMEScheme, DraftMIMEType: "text/html"},
@ -140,7 +155,7 @@ func TestPreferencesBuilder(t *testing.T) {
{
name: "wkd-external with global pgp-inline scheme",
contactMeta: &contactSettings{},
contactMeta: &contactSettings{EncryptUntrusted: true},
receivedKeys: []proton.PublicKey{{PublicKey: testPublicKey}},
isInternal: false,
mailSettings: proton.MailSettings{PGPScheme: proton.PGPInlineScheme, DraftMIMEType: "text/html"},
@ -155,7 +170,7 @@ func TestPreferencesBuilder(t *testing.T) {
{
name: "wkd-external with contact-specific pgp-inline scheme overriding global pgp-mime setting",
contactMeta: &contactSettings{Scheme: pgpInline},
contactMeta: &contactSettings{Scheme: pgpInline, EncryptUntrusted: true},
receivedKeys: []proton.PublicKey{{PublicKey: testPublicKey}},
isInternal: false,
mailSettings: proton.MailSettings{PGPScheme: proton.PGPMIMEScheme, DraftMIMEType: "text/html"},
@ -170,7 +185,7 @@ func TestPreferencesBuilder(t *testing.T) {
{
name: "wkd-external with contact-specific pgp-mime scheme overriding global pgp-inline setting",
contactMeta: &contactSettings{Scheme: pgpMIME},
contactMeta: &contactSettings{Scheme: pgpMIME, EncryptUntrusted: true},
receivedKeys: []proton.PublicKey{{PublicKey: testPublicKey}},
isInternal: false,
mailSettings: proton.MailSettings{PGPScheme: proton.PGPInlineScheme, DraftMIMEType: "text/html"},
@ -185,7 +200,7 @@ func TestPreferencesBuilder(t *testing.T) {
{
name: "wkd-external with additional pinned contact public key",
contactMeta: &contactSettings{Keys: []string{testContactKey}},
contactMeta: &contactSettings{Keys: []string{testContactKey}, EncryptUntrusted: true},
receivedKeys: []proton.PublicKey{{PublicKey: testPublicKey}},
isInternal: false,
mailSettings: proton.MailSettings{PGPScheme: proton.PGPMIMEScheme, DraftMIMEType: "text/html"},
@ -201,7 +216,7 @@ func TestPreferencesBuilder(t *testing.T) {
// NOTE: Need to figured out how to test that this calls the frontend to check for user confirmation.
name: "wkd-external with additional conflicting contact public key",
contactMeta: &contactSettings{Keys: []string{testOtherContactKey}},
contactMeta: &contactSettings{Keys: []string{testOtherContactKey}, EncryptUntrusted: true},
receivedKeys: []proton.PublicKey{{PublicKey: testPublicKey}},
isInternal: false,
mailSettings: proton.MailSettings{PGPScheme: proton.PGPMIMEScheme, DraftMIMEType: "text/html"},
@ -213,6 +228,51 @@ func TestPreferencesBuilder(t *testing.T) {
wantPublicKey: testPublicKey,
},
{
name: "wkd-external-with-encrypt-and-sign-disabled",
contactMeta: &contactSettings{EncryptUntrusted: false},
receivedKeys: []proton.PublicKey{{PublicKey: testPublicKey}},
isInternal: false,
mailSettings: proton.MailSettings{PGPScheme: proton.PGPMIMEScheme, DraftMIMEType: "text/html"},
wantEncrypt: false,
wantSign: proton.NoSignature,
wantScheme: proton.ClearScheme,
wantMIMEType: "text/html",
wantPublicKey: testPublicKey,
},
{
name: "wkd-external-with-encrypt-and-sign-disabled-plain-text",
contactMeta: &contactSettings{EncryptUntrusted: false},
receivedKeys: []proton.PublicKey{{PublicKey: testPublicKey}},
isInternal: false,
mailSettings: proton.MailSettings{PGPScheme: proton.PGPMIMEScheme, DraftMIMEType: "text/plain"},
wantEncrypt: false,
wantSign: proton.NoSignature,
wantScheme: proton.ClearScheme,
wantMIMEType: "text/plain",
wantPublicKey: testPublicKey,
},
{
name: "wkd-external-with-encrypt-disabled-sign-enabled",
contactMeta: &contactSettings{EncryptUntrusted: false, Sign: true, SignIsSet: true},
receivedKeys: []proton.PublicKey{{PublicKey: testPublicKey}},
isInternal: false,
mailSettings: proton.MailSettings{PGPScheme: proton.PGPMIMEScheme, DraftMIMEType: "text/html"},
wantEncrypt: false,
wantSign: proton.DetachedSignature,
wantScheme: proton.ClearMIMEScheme,
wantMIMEType: "multipart/mixed",
wantPublicKey: testPublicKey,
},
{
name: "external",

View File

@ -58,6 +58,10 @@ func (s Status) IsComplete() bool {
return s.HasLabels && s.HasMessages
}
func (s Status) InProgress() bool {
return s.HasLabels || s.HasMessageCount
}
// Regulator is an abstraction for the sync service, since it regulates the number of concurrent sync activities.
type Regulator interface {
Sync(ctx context.Context, stage *Job)

View File

@ -36,6 +36,20 @@ func (m *MockHeartbeatManager) EXPECT() *MockHeartbeatManagerMockRecorder {
return m.recorder
}
// GetHeartbeatPeriodicInterval mocks base method.
func (m *MockHeartbeatManager) GetHeartbeatPeriodicInterval() time.Duration {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetHeartbeatPeriodicInterval")
ret0, _ := ret[0].(time.Duration)
return ret0
}
// GetHeartbeatPeriodicInterval indicates an expected call of GetHeartbeatPeriodicInterval.
func (mr *MockHeartbeatManagerMockRecorder) GetHeartbeatPeriodicInterval() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHeartbeatPeriodicInterval", reflect.TypeOf((*MockHeartbeatManager)(nil).GetHeartbeatPeriodicInterval))
}
// GetLastHeartbeatSent mocks base method.
func (m *MockHeartbeatManager) GetLastHeartbeatSent() time.Time {
m.ctrl.T.Helper()

View File

@ -42,6 +42,7 @@ type HeartbeatManager interface {
SendHeartbeat(ctx context.Context, heartbeat *HeartbeatData) bool
GetLastHeartbeatSent() time.Time
SetLastHeartbeatSent(time.Time) error
GetHeartbeatPeriodicInterval() time.Duration
}
type HeartbeatValues struct {

View File

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

View File

@ -33,7 +33,7 @@ func migrateSyncStatusFromVault(encVault *vault.User, syncConfigDir string, user
}
if migrated {
if err := encVault.ClearSyncStatus(); err != nil {
if err := encVault.ClearSyncStatusWithoutEventID(); err != nil {
return fmt.Errorf("failed to clear sync settings from vault: %w", err)
}
}

View File

@ -105,6 +105,7 @@ func New(
eventSubscription events.Subscription,
syncService syncservice.Regulator,
syncConfigDir string,
isNew bool,
) (*User, error) {
user, err := newImpl(
ctx,
@ -122,6 +123,7 @@ func New(
eventSubscription,
syncService,
syncConfigDir,
isNew,
)
if err != nil {
// Cleanup any pending resources on error
@ -152,6 +154,7 @@ func newImpl(
eventSubscription events.Subscription,
syncService syncservice.Regulator,
syncConfigDir string,
isNew bool,
) (*User, error) {
logrus.WithField("userID", apiUser.ID).Info("Creating new user")
@ -295,6 +298,14 @@ func newImpl(
return nil
})
// If it's not a fresh user check the eventID and evaluate whether it is valid. If it's a new user, we don't
// need to perform this check.
if !isNew {
if err := checkIrrecoverableEventID(ctx, encVault.EventID(), apiUser.ID, syncConfigDir, user); err != nil {
return nil, err
}
}
// Start Event Service
lastEventID, err := user.eventService.Start(ctx, user.serviceGroup)
if err != nil {
@ -368,24 +379,28 @@ func (user *User) Match(query string) bool {
return false
}
// Emails returns all the user's active email addresses.
// It returns them in sorted order; the user's primary address is first.
func (user *User) Emails() []string {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(time.Minute))
defer cancel()
apiAddrs, err := user.identityService.GetAddresses(ctx)
if err != nil {
// DisplayNames returns a map of the email addresses and their associated display names.
func (user *User) DisplayNames() map[string]string {
addresses := user.protonAddresses()
if addresses == nil {
return nil
}
addresses := xslices.Filter(maps.Values(apiAddrs), func(addr proton.Address) bool {
return addr.Status == proton.AddressStatusEnabled && addr.Type != proton.AddressTypeExternal
})
result := make(map[string]string)
for _, address := range addresses {
result[address.Email] = address.DisplayName
}
slices.SortFunc(addresses, func(a, b proton.Address) bool {
return a.Order < b.Order
})
return result
}
// Emails returns all the user's active email addresses.
// It returns them in sorted order; the user's primary address is first.
func (user *User) Emails() []string {
addresses := user.protonAddresses()
if addresses == nil {
return nil
}
return xslices.Map(addresses, func(addr proton.Address) string {
return addr.Email
@ -682,3 +697,23 @@ func (user *User) PauseEventLoopWithWaiter() *userevents.EventPollWaiter {
func (user *User) ResumeEventLoop() {
user.eventService.Resume()
}
func (user *User) protonAddresses() []proton.Address {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(time.Minute))
defer cancel()
apiAddrs, err := user.identityService.GetAddresses(ctx)
if err != nil {
return nil
}
addresses := xslices.Filter(maps.Values(apiAddrs), func(addr proton.Address) bool {
return addr.Status == proton.AddressStatusEnabled && addr.Type != proton.AddressTypeExternal
})
slices.SortFunc(addresses, func(a, b proton.Address) bool {
return a.Order < b.Order
})
return addresses
}

View File

@ -0,0 +1,68 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package user
import (
"context"
"fmt"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/services/imapservice"
)
func checkIrrecoverableEventID(
ctx context.Context,
lastEventID,
userID,
syncConfigDir string,
publisher events.EventPublisher,
) error {
// If we detect that the event ID stored in the vault got reset, the user is not a new account and
// we have started or finished syncing: this is an irrecoverable state and we should produce a bad event.
if lastEventID != "" {
return nil
}
syncConfigPath := imapservice.GetSyncConfigPath(syncConfigDir, userID)
syncState, err := imapservice.NewSyncState(syncConfigPath)
if err != nil {
return fmt.Errorf("failed to read imap sync state: %w", err)
}
syncStatus, err := syncState.GetSyncStatus(ctx)
if err != nil {
return fmt.Errorf("failed to imap sync status: %w", err)
}
if syncStatus.IsComplete() || syncStatus.InProgress() {
publisher.PublishEvent(ctx, newEmptyEventIDBadEvent(userID))
}
return nil
}
func newEmptyEventIDBadEvent(userID string) events.UserBadEvent {
return events.UserBadEvent{
UserID: userID,
OldEventID: "",
NewEventID: "",
EventInfo: "EventID missing from vault",
Error: fmt.Errorf("eventID in vault is empty, when it shouldn't be"),
}
}

View File

@ -0,0 +1,104 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package user
import (
"context"
"testing"
"github.com/ProtonMail/proton-bridge/v3/internal/events/mocks"
"github.com/ProtonMail/proton-bridge/v3/internal/services/imapservice"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/require"
)
func TestCheckIrrecoverableEventID_EventIDIsEmptyButNoSyncStarted(t *testing.T) {
tmpDir := t.TempDir()
userID := "foo"
mockCtrl := gomock.NewController(t)
publisher := mocks.NewMockEventPublisher(mockCtrl)
require.NoError(t, checkIrrecoverableEventID(context.Background(), "", userID, tmpDir, publisher))
}
func TestCheckIrrecoverableEventID_EventIDIsNotEmptyButNoSyncStarted(t *testing.T) {
tmpDir := t.TempDir()
userID := "foo"
mockCtrl := gomock.NewController(t)
publisher := mocks.NewMockEventPublisher(mockCtrl)
require.NoError(t, checkIrrecoverableEventID(context.Background(), "ffoofo", userID, tmpDir, publisher))
}
func TestCheckIrrecoverableEventID_EventIDIsEmptyButSyncStarted(t *testing.T) {
tmpDir := t.TempDir()
userID := "foo"
mockCtrl := gomock.NewController(t)
publisher := mocks.NewMockEventPublisher(mockCtrl)
publisher.EXPECT().PublishEvent(gomock.Any(), gomock.Eq(newEmptyEventIDBadEvent(userID)))
require.NoError(t, genSyncState(context.Background(), userID, tmpDir, false))
require.NoError(t, checkIrrecoverableEventID(context.Background(), "", userID, tmpDir, publisher))
}
func TestCheckIrrecoverableEventID_EventIDIsEmptyButSyncFinished(t *testing.T) {
tmpDir := t.TempDir()
userID := "foo"
mockCtrl := gomock.NewController(t)
publisher := mocks.NewMockEventPublisher(mockCtrl)
publisher.EXPECT().PublishEvent(gomock.Any(), gomock.Eq(newEmptyEventIDBadEvent(userID)))
require.NoError(t, genSyncState(context.Background(), userID, tmpDir, true))
require.NoError(t, checkIrrecoverableEventID(context.Background(), "", userID, tmpDir, publisher))
}
func TestCheckIrrecoverableEventID_EventIDIsNotEmptyButSyncFinished(t *testing.T) {
tmpDir := t.TempDir()
userID := "foo"
mockCtrl := gomock.NewController(t)
publisher := mocks.NewMockEventPublisher(mockCtrl)
require.NoError(t, genSyncState(context.Background(), userID, tmpDir, true))
require.NoError(t, checkIrrecoverableEventID(context.Background(), "some event", userID, tmpDir, publisher))
}
func genSyncState(ctx context.Context, userID, dir string, finished bool) error {
s, err := imapservice.NewSyncState(imapservice.GetSyncConfigPath(dir, userID))
if err != nil {
return err
}
if finished {
if err := s.SetHasLabels(ctx, true); err != nil {
return err
}
if err := s.SetHasMessages(ctx, true); err != nil {
return err
}
if err := s.SetMessageCount(ctx, 10); err != nil {
return err
}
} else {
if err := s.SetHasLabels(ctx, true); err != nil {
return err
}
}
return nil
}

View File

@ -19,6 +19,7 @@ package user
import (
"context"
"reflect"
"testing"
"time"
@ -58,8 +59,12 @@ func TestUser_Info(t *testing.T) {
// User's name should be correct.
require.Equal(t, "username", user.Name())
// User's email should be correct.
// User's emails should be correct and their associated display names should be correct
require.ElementsMatch(t, []string{"username@" + s.GetDomain(), "alias@pm.me"}, user.Emails())
require.True(t, reflect.DeepEqual(map[string]string{
"username@" + s.GetDomain(): "username" + " (Display Name)",
"alias@pm.me": "alias@pm.me (Display Name)",
}, user.DisplayNames()))
// By default, user should be in combined mode.
require.Equal(t, vault.CombinedMode, user.GetAddressMode())
@ -98,12 +103,14 @@ func withAPI(_ testing.TB, ctx context.Context, fn func(context.Context, *server
func withAccount(tb testing.TB, s *server.Server, username, password string, aliases []string, fn func(string, []string)) { //nolint:unparam
userID, addrID, err := s.CreateUser(username, []byte(password))
require.NoError(tb, err)
require.NoError(tb, s.ChangeAddressDisplayName(userID, addrID, username+" (Display Name)"))
addrIDs := []string{addrID}
for _, email := range aliases {
addrID, err := s.CreateAddress(userID, email, []byte(password))
require.NoError(tb, err)
require.NoError(tb, s.ChangeAddressDisplayName(userID, addrID, email+" (Display Name)"))
addrIDs = append(addrIDs, addrID)
}
@ -158,6 +165,7 @@ func withUser(tb testing.TB, ctx context.Context, _ *server.Server, m *proton.Ma
nullEventSubscription,
nil,
"",
true,
)
require.NoError(tb, err)
defer user.Close()

View File

@ -186,13 +186,13 @@ func (user *User) RemFailedMessageID(messageID string) error {
})
}
// GetSyncStatus returns the user's sync status.
func (user *User) GetSyncStatus() SyncStatus {
// GetSyncStatusDeprecated returns the user's sync status.
func (user *User) GetSyncStatusDeprecated() SyncStatus {
return user.vault.getUser(user.userID).SyncStatus
}
// ClearSyncStatus clears the user's sync status.
func (user *User) ClearSyncStatus() error {
// ClearSyncStatusDeprecated clears the user's sync status.
func (user *User) ClearSyncStatusDeprecated() error {
return user.vault.modUser(user.userID, func(data *UserData) {
data.SyncStatus = SyncStatus{}
@ -200,6 +200,13 @@ func (user *User) ClearSyncStatus() error {
})
}
// ClearSyncStatusWithoutEventID clears the user's sync status without modifying EventID.
func (user *User) ClearSyncStatusWithoutEventID() error {
return user.vault.modUser(user.userID, func(data *UserData) {
data.SyncStatus = SyncStatus{}
})
}
// EventID returns the last processed event ID of the user.
func (user *User) EventID() string {
return user.vault.getUser(user.userID).EventID

View File

@ -137,7 +137,7 @@ func TestUser_SyncStatus(t *testing.T) {
require.True(t, user.SyncStatus().HasMessages)
// Clear the sync status.
require.NoError(t, user.ClearSyncStatus())
require.NoError(t, user.ClearSyncStatusDeprecated())
// Check the user's cleared sync status.
require.False(t, user.SyncStatus().HasLabels)
@ -145,6 +145,31 @@ func TestUser_SyncStatus(t *testing.T) {
require.Empty(t, user.SyncStatus().LastMessageID)
}
func TestUser_ClearSyncStatusWithoutEventID(t *testing.T) {
// Create a new test vault.
s := newVault(t)
// Create a new user.
user, err := s.AddUser("userID", "username", "username@pm.me", "authUID", "authRef", []byte("keyPass"))
require.NoError(t, err)
// Simulate finishing the sync.
require.NoError(t, user.SetHasLabels(true))
require.NoError(t, user.SetHasMessages(true))
require.True(t, user.SyncStatus().HasLabels)
require.True(t, user.SyncStatus().HasMessages)
require.NoError(t, user.SetEventID("foo"))
// Clear the sync status.
require.NoError(t, user.ClearSyncStatusWithoutEventID())
// Check the user's cleared sync status.
require.False(t, user.SyncStatus().HasLabels)
require.False(t, user.SyncStatus().HasMessages)
require.Empty(t, user.SyncStatus().LastMessageID)
require.Equal(t, "foo", user.EventID())
}
func TestUser_PrimaryEmail(t *testing.T) {
// Create a new test vault.
s := newVault(t)

View File

@ -31,14 +31,18 @@ const (
MacOSKeychain = "macos-keychain"
)
func init() { //nolint:gochecknoinits
Helpers = make(map[string]helperConstructor)
func listHelpers() (Helpers, string) {
helpers := make(Helpers)
// MacOS always provides a keychain.
Helpers[MacOSKeychain] = newMacOSHelper
if isUsable(newMacOSHelper("")) {
helpers[MacOSKeychain] = newMacOSHelper
} else {
logrus.WithField("keychain", "MacOSKeychain").Warn("Keychain is not available.")
}
// Use MacOSKeychain by default.
DefaultHelper = MacOSKeychain
return helpers, MacOSKeychain
}
func parseError(original error) error {

View File

@ -18,8 +18,6 @@
package keychain
import (
"reflect"
"github.com/docker/docker-credential-helpers/credentials"
"github.com/docker/docker-credential-helpers/pass"
"github.com/docker/docker-credential-helpers/secretservice"
@ -33,30 +31,37 @@ const (
SecretServiceDBus = "secret-service-dbus"
)
func init() { //nolint:gochecknoinits
Helpers = make(map[string]helperConstructor)
func listHelpers() (Helpers, string) {
helpers := make(Helpers)
if isUsable(newDBusHelper("")) {
Helpers[SecretServiceDBus] = newDBusHelper
helpers[SecretServiceDBus] = newDBusHelper
} else {
logrus.WithField("keychain", "SecretServiceDBus").Warn("Keychain is not available.")
}
if _, err := execabs.LookPath("gnome-keyring"); err == nil && isUsable(newSecretServiceHelper("")) {
Helpers[SecretService] = newSecretServiceHelper
helpers[SecretService] = newSecretServiceHelper
} else {
logrus.WithField("keychain", "SecretService").Warn("Keychain is not available.")
}
if _, err := execabs.LookPath("pass"); err == nil && isUsable(newPassHelper("")) {
Helpers[Pass] = newPassHelper
helpers[Pass] = newPassHelper
} else {
logrus.WithField("keychain", "Pass").Warn("Keychain is not available.")
}
DefaultHelper = SecretServiceDBus
defaultHelper := SecretServiceDBus
// If Pass is available, use it by default.
// Otherwise, if SecretService is available, use it by default.
if _, ok := Helpers[Pass]; ok {
DefaultHelper = Pass
} else if _, ok := Helpers[SecretService]; ok {
DefaultHelper = SecretService
if _, ok := helpers[Pass]; ok {
defaultHelper = Pass
} else if _, ok := helpers[SecretService]; ok {
defaultHelper = SecretService
}
return helpers, defaultHelper
}
func newDBusHelper(string) (credentials.Helper, error) {
@ -70,36 +75,3 @@ func newPassHelper(string) (credentials.Helper, error) {
func newSecretServiceHelper(string) (credentials.Helper, error) {
return &secretservice.Secretservice{}, nil
}
// isUsable returns whether the credentials helper is usable.
func isUsable(helper credentials.Helper, err error) bool {
l := logrus.WithField("helper", reflect.TypeOf(helper))
if err != nil {
l.WithError(err).Warn("Keychain helper couldn't be created")
return false
}
creds := &credentials.Credentials{
ServerURL: "bridge/check",
Username: "check",
Secret: "check",
}
if err := helper.Add(creds); err != nil {
l.WithError(err).Warn("Failed to add test credentials to keychain")
return false
}
if _, _, err := helper.Get(creds.ServerURL); err != nil {
l.WithError(err).Warn("Failed to get test credentials from keychain")
return false
}
if err := helper.Delete(creds.ServerURL); err != nil {
l.WithError(err).Warn("Failed to delete test credentials from keychain")
return false
}
return true
}

View File

@ -20,18 +20,21 @@ package keychain
import (
"github.com/docker/docker-credential-helpers/credentials"
"github.com/docker/docker-credential-helpers/wincred"
"github.com/sirupsen/logrus"
)
const WindowsCredentials = "windows-credentials"
func init() { //nolint:gochecknoinits
Helpers = make(map[string]helperConstructor)
func listHelpers() (Helpers, string) {
helpers := make(Helpers)
// Windows always provides a keychain.
Helpers[WindowsCredentials] = newWinCredHelper
if isUsable(newWinCredHelper("")) {
helpers[WindowsCredentials] = newWinCredHelper
} else {
logrus.WithField("keychain", "WindowsCredentials").Warn("Keychain is not available.")
}
// Use WindowsCredentials by default.
DefaultHelper = WindowsCredentials
return helpers, WindowsCredentials
}
func newWinCredHelper(string) (credentials.Helper, error) {

View File

@ -21,9 +21,12 @@ package keychain
import (
"errors"
"fmt"
"reflect"
"sync"
"time"
"github.com/docker/docker-credential-helpers/credentials"
"github.com/sirupsen/logrus"
)
// helperConstructor constructs a keychain helperConstructor.
@ -38,28 +41,53 @@ var (
// ErrMacKeychainRebuild is returned on macOS with blocked or corrupted keychain.
ErrMacKeychainRebuild = errors.New("keychain error -25293")
// Helpers holds all discovered keychain helpers. It is populated in init().
Helpers map[string]helperConstructor //nolint:gochecknoglobals
// DefaultHelper is the default helper to use if the user hasn't yet set a preference.
DefaultHelper string //nolint:gochecknoglobals
)
type Helpers map[string]helperConstructor
type List struct {
helpers Helpers
defaultHelper string
locker sync.Locker
}
// NewList checks availability of every keychains detected on the User Operating System
// This will ask the user to unlock keychain(s) to check their usability.
// This should only be called once.
func NewList() *List {
var list = List{locker: &sync.Mutex{}}
list.helpers, list.defaultHelper = listHelpers()
return &list
}
func (kcl *List) GetHelpers() Helpers {
kcl.locker.Lock()
defer kcl.locker.Unlock()
return kcl.helpers
}
func (kcl *List) GetDefaultHelper() string {
kcl.locker.Lock()
defer kcl.locker.Unlock()
return kcl.defaultHelper
}
// NewKeychain creates a new native keychain.
func NewKeychain(preferred, keychainName string) (*Keychain, error) {
func NewKeychain(preferred, keychainName string, helpers Helpers, defaultHelper string) (*Keychain, error) {
// There must be at least one keychain helper available.
if len(Helpers) < 1 {
if len(helpers) < 1 {
return nil, ErrNoKeychain
}
// If the preferred keychain is unsupported, fallback to the default one.
if _, ok := Helpers[preferred]; !ok {
preferred = DefaultHelper
if _, ok := helpers[preferred]; !ok {
preferred = defaultHelper
}
// Load the user's preferred keychain helper.
helperConstructor, ok := Helpers[preferred]
helperConstructor, ok := helpers[preferred]
if !ok {
return nil, ErrNoKeychain
}
@ -163,3 +191,49 @@ func (kc *Keychain) Put(userID, secret string) error {
func (kc *Keychain) secretURL(userID string) string {
return fmt.Sprintf("%v/%v", kc.url, userID)
}
// isUsable returns whether the credentials helper is usable.
func isUsable(helper credentials.Helper, err error) bool {
l := logrus.WithField("helper", reflect.TypeOf(helper))
if err != nil {
l.WithError(err).Warn("Keychain helper couldn't be created")
return false
}
creds := &credentials.Credentials{
ServerURL: "bridge/check",
Username: "check",
Secret: "check",
}
if err := retry(func() error {
return helper.Add(creds)
}); err != nil {
l.WithError(err).Warn("Failed to add test credentials to keychain")
return false
}
if _, _, err := helper.Get(creds.ServerURL); err != nil {
l.WithError(err).Warn("Failed to get test credentials from keychain")
return false
}
if err := helper.Delete(creds.ServerURL); err != nil {
l.WithError(err).Warn("Failed to delete test credentials from keychain")
return false
}
return true
}
func retry(condition func() error) error {
var maxRetry = 5
for r := 0; ; r++ {
err := condition()
if err == nil || r >= maxRetry {
return err
}
time.Sleep(200 * time.Millisecond)
}
}

View File

@ -17,10 +17,22 @@
package keychain
import "github.com/docker/docker-credential-helpers/credentials"
import (
"sync"
"github.com/docker/docker-credential-helpers/credentials"
)
type TestHelper map[string]*credentials.Credentials
func NewTestKeychainsList() *List {
keychainHelper := NewTestHelper()
helpers := make(Helpers)
helpers["mock"] = func(string) (credentials.Helper, error) { return keychainHelper, nil }
var list = List{helpers: helpers, defaultHelper: "mock", locker: &sync.Mutex{}}
return &list
}
func NewTestHelper() TestHelper {
return make(TestHelper)
}

View File

@ -32,6 +32,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/pkg/message/parser"
pmmime "github.com/ProtonMail/proton-bridge/v3/pkg/mime"
"github.com/emersion/go-message"
"github.com/google/uuid"
"github.com/jaytaylor/html2text"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
@ -59,6 +60,7 @@ type Message struct {
References []string
ExternalID string
InReplyTo string
XForward string
}
type Attachment struct {
@ -116,6 +118,10 @@ func parse(p *parser.Parser, allowInvalidAddressLists bool) (Message, error) {
return Message{}, errors.Wrap(err, "failed to convert foreign encodings")
}
if err := patchInlineImages(p); err != nil {
return Message{}, err
}
m, err := parseMessageHeader(p.Root().Header, allowInvalidAddressLists)
if err != nil {
return Message{}, errors.Wrap(err, "failed to parse message header")
@ -142,7 +148,7 @@ func parse(p *parser.Parser, allowInvalidAddressLists bool) (Message, error) {
m.PlainBody = Body(plainBody)
m.MIMEBody = MIMEBody(mimeBody)
mimeType, err := determineMIMEType(p)
mimeType, err := determineBodyMIMEType(p)
if err != nil {
return Message{}, errors.Wrap(err, "failed to get mime type")
}
@ -308,24 +314,14 @@ func collectBodyParts(p *parser.Parser, preferredContentType string) (parser.Par
return bestChoice(childParts, preferredContentType), nil
}).
RegisterRule("text/plain", func(p *parser.Part, visit parser.Visit) (interface{}, error) {
disp, _, err := p.Header.ContentDisposition()
if err != nil {
disp = ""
}
if disp == "attachment" {
if p.IsAttachment() {
return parser.Parts{}, nil
}
return parser.Parts{p}, nil
}).
RegisterRule("text/html", func(p *parser.Part, visit parser.Visit) (interface{}, error) {
disp, _, err := p.Header.ContentDisposition()
if err != nil {
disp = ""
}
if disp == "attachment" {
if p.IsAttachment() {
return parser.Parts{}, nil
}
@ -400,7 +396,7 @@ func allPartsHaveContentType(parts parser.Parts, contentType string) bool {
return true
}
func determineMIMEType(p *parser.Parser) (string, error) {
func determineBodyMIMEType(p *parser.Parser) (string, error) {
var isHTML bool
w := p.NewWalker().
@ -409,7 +405,7 @@ func determineMIMEType(p *parser.Parser) (string, error) {
return
})
if err := w.Walk(); err != nil {
if err := w.WalkSkipAttachment(); err != nil {
return "", err
}
@ -525,6 +521,9 @@ func parseMessageHeader(h message.Header, allowInvalidAddressLists bool) (Messag
case "in-reply-to":
m.InReplyTo = regexp.MustCompile("<(.*)>").ReplaceAllString(fields.Value(), "$1")
case "x-forwarded-message-id":
m.XForward = regexp.MustCompile("<(.*)>").ReplaceAllString(fields.Value(), "$1")
case "references":
for _, ref := range strings.Fields(fields.Value()) {
for _, ref := range strings.Split(ref, ",") {
@ -636,3 +635,168 @@ func forEachDecodedHeaderField(h message.Header, fn func(string, string) error)
return nil
}
func patchInlineImages(p *parser.Parser) error {
// This code will only attempt to patch the root level children. I tested with different email clients and as soon
// as you reply/forward a message the entire content gets converted into HTML (Apple Mail/Thunderbird/Evolution).
// If you are forcing text formatting (Evolution), the inline images of the original email are stripped.
// The only reason we need to apply this modification is that Apple Mail can send out text + inline image parts
// if the text does not exceed the 76 char column limit.
// Based on this, it's unlikely we will see any other variations.
root := p.Root()
children := root.Children()
if len(children) < 2 {
return nil
}
result := make([]inlinePatchJob, len(children))
var (
transformationNeeded bool
prevPart *parser.Part
prevContentType string
prevContentTypeMap map[string]string
)
for i := 0; i < len(children); i++ {
curPart := children[i]
contentType, contentTypeMap, err := curPart.ContentType()
if err != nil {
return fmt.Errorf("failed to get content type for for child %v:%w", i, err)
}
if rfc822.MIMEType(contentType) == rfc822.TextPlain {
result[i] = &inlinePatchBodyOnly{part: curPart, contentTypeMap: contentTypeMap}
} else if strings.HasPrefix(contentType, "image/") {
disposition, _, err := curPart.ContentDisposition()
if err != nil {
return fmt.Errorf("failted to get content disposition for child %v:%w", i, err)
}
if disposition == "inline" && !curPart.HasContentID() {
if rfc822.MIMEType(prevContentType) == rfc822.TextPlain {
result[i-1] = &inlinePatchBodyWithInlineImage{
textPart: prevPart,
imagePart: curPart,
textContentTypeMap: prevContentTypeMap,
}
} else {
result[i] = &inlinePatchInlineImageOnly{part: curPart, partIndex: i, root: root}
}
transformationNeeded = true
}
}
prevPart = curPart
prevContentType = contentType
prevContentTypeMap = contentTypeMap
}
if !transformationNeeded {
return nil
}
for _, t := range result {
if t != nil {
t.Patch()
}
}
return nil
}
type inlinePatchJob interface {
Patch()
}
// inlinePatchBodyOnly is meant to be used for standalone text parts that need to be converted to html once we applty
// one of the changes.
type inlinePatchBodyOnly struct {
part *parser.Part
contentTypeMap map[string]string
}
func (i *inlinePatchBodyOnly) Patch() {
newBody := []byte(`<html><body><p>`)
newBody = append(newBody, patchNewLineWithHTMLBreaks(i.part.Body)...)
newBody = append(newBody, []byte(`</p></body></html>`)...)
i.part.Body = newBody
i.part.Header.SetContentType("text/html", i.contentTypeMap)
}
// inlinePatchBodyWithInlineImage patches a previous text part so that it refers to that inline image.
type inlinePatchBodyWithInlineImage struct {
textPart *parser.Part
textContentTypeMap map[string]string
imagePart *parser.Part
}
// inlinePatchInlineImageOnly handle the case where the inline image is not proceeded by a text part. To avoid
// having to parse any possible previous part, we just inject a new part that references this image.
type inlinePatchInlineImageOnly struct {
part *parser.Part
partIndex int
root *parser.Part
}
func (i inlinePatchInlineImageOnly) Patch() {
contentID := uuid.NewString()
// Convert previous part to text/html && inject image.
newBody := []byte(fmt.Sprintf(`<html><body><img src="cid:%v"/></body></html>`, contentID))
i.part.Header.Set("content-id", contentID)
// create new text part
textPart := &parser.Part{
Header: message.Header{},
Body: newBody,
}
textPart.Header.SetContentType("text/html", map[string]string{"charset": "UTF-8"})
i.root.InsertChild(i.partIndex, textPart)
}
func (i *inlinePatchBodyWithInlineImage) Patch() {
contentID := uuid.NewString()
// Convert previous part to text/html && inject image.
newBody := []byte(`<html><body><p>`)
newBody = append(newBody, patchNewLineWithHTMLBreaks(i.textPart.Body)...)
newBody = append(newBody, []byte(`</p>`)...)
newBody = append(newBody, []byte(fmt.Sprintf(`<img src="cid:%v"/>`, contentID))...)
newBody = append(newBody, []byte(`</body></html>`)...)
i.textPart.Body = newBody
i.textPart.Header.SetContentType("text/html", i.textContentTypeMap)
// Add content id to curPart
i.imagePart.Header.Set("content-id", contentID)
}
func patchNewLineWithHTMLBreaks(input []byte) []byte {
dst := make([]byte, 0, len(input))
index := 0
for {
slice := input[index:]
newLineIndex := bytes.IndexByte(slice, '\n')
if newLineIndex == -1 {
dst = append(dst, input[index:]...)
return dst
}
injectIndex := newLineIndex
if newLineIndex > 0 && slice[newLineIndex-1] == '\r' {
injectIndex--
}
dst = append(dst, slice[0:injectIndex]...)
dst = append(dst, '<', 'b', 'r', '/', '>')
dst = append(dst, slice[injectIndex:newLineIndex+1]...)
index += newLineIndex + 1
}
}

View File

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

View File

@ -27,6 +27,7 @@ import (
"github.com/PuerkitoBio/goquery"
"github.com/emersion/go-message"
"github.com/sirupsen/logrus"
"golang.org/x/exp/slices"
"golang.org/x/net/html"
"golang.org/x/net/html/charset"
"golang.org/x/text/encoding"
@ -40,6 +41,8 @@ type Part struct {
children Parts
}
const utf8Charset = "UTF-8"
func (p *Part) ContentType() (string, map[string]string, error) {
t, params, err := p.Header.ContentType()
if err != nil {
@ -52,6 +55,14 @@ func (p *Part) ContentType() (string, map[string]string, error) {
return t, params, err
}
func (p *Part) ContentDisposition() (string, map[string]string, error) {
return pmmime.ParseMediaType(p.Header.Get("Content-Disposition"))
}
func (p *Part) HasContentID() bool {
return len(p.Header.Get("content-id")) != 0
}
func (p *Part) Child(n int) (part *Part, err error) {
if len(p.children) < n {
return nil, errors.New("no such part")
@ -81,6 +92,14 @@ func (p *Part) AddChild(child *Part) {
}
}
func (p *Part) InsertChild(index int, child *Part) {
if p.isMultipartMixedOrRelated() {
p.children = slices.Insert(p.children, index, child)
} else {
p.AddChild(child)
}
}
func (p *Part) ConvertToUTF8() error {
logrus.Trace("Converting part to utf-8")
@ -99,7 +118,7 @@ func (p *Part) ConvertToUTF8() error {
params = make(map[string]string)
}
params["charset"] = "UTF-8"
params["charset"] = utf8Charset
p.Header.SetContentType(t, params)
@ -112,6 +131,8 @@ func (p *Part) ConvertMetaCharset() error {
return err
}
// Override charset to UTF-8 in meta headers only if needed.
var metaModified = false
goquery.NewDocumentFromNode(doc).Find("meta").Each(func(n int, sel *goquery.Selection) {
if val, ok := sel.Attr("content"); ok {
t, params, err := pmmime.ParseMediaType(val)
@ -119,24 +140,31 @@ func (p *Part) ConvertMetaCharset() error {
return
}
params["charset"] = "UTF-8"
if charset, ok := params["charset"]; ok && charset != utf8Charset {
params["charset"] = utf8Charset
}
sel.SetAttr("content", mime.FormatMediaType(t, params))
metaModified = true
}
if _, ok := sel.Attr("charset"); ok {
sel.SetAttr("charset", "UTF-8")
if charset, ok := sel.Attr("charset"); ok && charset != utf8Charset {
sel.SetAttr("charset", utf8Charset)
metaModified = true
}
})
buf := new(bytes.Buffer)
// Override the body part only if modification was applied
// as html.render will sanitise the html headers.
if metaModified {
buf := new(bytes.Buffer)
if err := html.Render(buf, doc); err != nil {
return err
if err := html.Render(buf, doc); err != nil {
return err
}
p.Body = buf.Bytes()
}
p.Body = buf.Bytes()
return nil
}
@ -183,6 +211,23 @@ func (p *Part) isMultipartMixed() bool {
return t == "multipart/mixed"
}
func (p *Part) isMultipartMixedOrRelated() bool {
t, _, err := p.ContentType()
if err != nil {
return false
}
return t == "multipart/mixed" || t == "multipart/related"
}
func (p *Part) IsAttachment() bool {
disp, _, err := p.ContentDisposition()
if err != nil {
disp = ""
}
return disp == "attachment"
}
func getContentHeaders(header message.Header) message.Header {
var res message.Header

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
From: Sender <sender@pm.me>
To: Receiver <receiver@pm.me>
Content-Type: text/plain; charset=utf-8
X-Forwarded-Message-Id: <00000@protonmail.com>
In-Reply-To: <00000@protonmail.com>
body

View File

@ -255,11 +255,20 @@ 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) {
func ParseMediaType(v string) (string, map[string]string, error) {
if v == "" {
return "", nil, errors.New("empty media type")
}
decoded, err := DecodeHeader(v)
if err != nil {
logrus.WithField("value", v).WithError(err).Error("Media Type parsing error.")
return "", nil, err
}
v, _ = changeEncodingAndKeepLastParamDefinition(decoded)
return mime.ParseMediaType(v)
mediatype, params, err := mime.ParseMediaType(v)
if err != nil {
logrus.WithField("value", v).WithError(err).Error("Media Type parsing error.")
return "", nil, err
}
return mediatype, params, err
}

View File

@ -24,6 +24,7 @@ import (
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"time"
@ -177,6 +178,18 @@ func newTestBugReport(br *bridge.Bridge) *testBugReport {
}
func (r *testBugReport) report() error {
if r.request.IncludeLogs == true {
data := []byte("Test log file.\n")
logName := "20231031_122940334_bri_000_v3.6.1+qa_br-178.log"
logPath, err := r.bridge.GetLogsPath()
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(logPath, logName), data, 0o600); err != nil {
return err
}
}
return r.bridge.ReportBug(context.Background(), &r.request)
}
@ -202,7 +215,7 @@ func (s *scenario) theUserReportsABugWithSingleHeaderChange(key, value string) e
bugReport.request.Email = value
case "Client":
bugReport.request.EmailClient = value
case "Attachment":
case "IncludeLogs":
att, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("failed to parse bug report attachment preferences: %w", err)

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.Response.Code != proton.SuccessCode {
return errors.New("APIError " + res[0].Response.Response.Message + " while creating contact")
if res[0].Response.Code != proton.SuccessCode {
return errors.New("APIError " + res[0].Response.Message + " while creating contact")
}
if settings != nil {

View File

@ -39,6 +39,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/service"
"github.com/ProtonMail/proton-bridge/v3/internal/useragent"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/pkg/keychain"
"github.com/sirupsen/logrus"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
@ -153,6 +154,7 @@ func (t *testCtx) initBridge() (<-chan events.Event, error) {
t.mocks.Autostarter,
t.mocks.Updater,
t.version,
keychain.NewTestKeychainsList(),
// API stuff
t.api.GetHostURL(),
@ -164,6 +166,7 @@ func (t *testCtx) initBridge() (<-chan events.Event, error) {
t.mocks.CrashHandler,
t.reporter,
imap.DefaultEpochUIDValidityGenerator(),
t.heartbeat,
// Logging stuff
logIMAP,
@ -177,8 +180,6 @@ func (t *testCtx) initBridge() (<-chan events.Event, error) {
t.bridge = bridge
t.heartbeat.setBridge(bridge)
bridge.StartHeartbeat(t.heartbeat)
return t.events.collectFrom(eventCh), nil
}

View File

@ -85,6 +85,10 @@ func (hb *heartbeatRecorder) SetLastHeartbeatSent(timestamp time.Time) error {
return hb.bridge.SetLastHeartbeatSent(timestamp)
}
func (hb *heartbeatRecorder) GetHeartbeatPeriodicInterval() time.Duration {
return 200 * time.Millisecond
}
func (hb *heartbeatRecorder) rejectSend() {
hb.reject = true
}

View File

@ -91,44 +91,71 @@ func (s *scenario) theHeaderInTheRequestToHasSetTo(method, path, key, value stri
}
func (s *scenario) theHeaderInTheMultipartRequestToHasSetTo(method, path, key, value string) error {
req, err := s.getLastCallMultipartForm(method, path)
if err != nil {
return fmt.Errorf("failed to parse multipart form: %w", err)
}
if haveValue := req.FormValue(key); haveValue != value {
return fmt.Errorf("header field %q have %q, want %q", key, haveValue, value)
}
return nil
}
func (s *scenario) checkParsedMultipartFormForFile(method, path, file string, hasFile bool) error {
req, err := s.getLastCallMultipartForm(method, path)
if err != nil {
return fmt.Errorf("failed to parse multipart form: %w", err)
}
if _, ok := req.MultipartForm.File[file]; hasFile != ok {
return fmt.Errorf("Multipart file in bug report is %t, want it to be %t", ok, hasFile)
}
return nil
}
func (s *scenario) theHeaderInTheMultipartRequestToHasFile(method, path, file string) error {
return s.checkParsedMultipartFormForFile(method, path, file, true)
}
func (s *scenario) theHeaderInTheMultipartRequestToHasNoFile(method, path, file string) error {
return s.checkParsedMultipartFormForFile(method, path, file, false)
}
func (s *scenario) getLastCallMultipartForm(method, path string) (*http.Request, error) {
// We have to exclude HTTP-Overrides to avoid race condition with the creating and sending of the draft message.
call, err := s.t.getLastCallExcludingHTTPOverride(method, path)
if err != nil {
return err
return nil, err
}
buf := new(bytes.Buffer)
if _, err := buf.WriteString(fmt.Sprintf("%s %s HTTP/1.1\r\n", call.Method, call.URL.Path)); err != nil {
return fmt.Errorf("failed to write request line: %w", err)
return nil, fmt.Errorf("failed to write request line: %w", err)
}
if err := call.RequestHeader.Write(buf); err != nil {
return fmt.Errorf("failed to write header: %w", err)
return nil, fmt.Errorf("failed to write header: %w", err)
}
if _, err := buf.WriteString("\r\n"); err != nil {
return fmt.Errorf("failed to write header: %w", err)
return nil, fmt.Errorf("failed to write header: %w", err)
}
if _, err := buf.Write(call.RequestBody); err != nil {
return fmt.Errorf("failed to write body: %w", err)
return nil, fmt.Errorf("failed to write body: %w", err)
}
req, err := http.ReadRequest(bufio.NewReader(buf))
if err != nil {
return fmt.Errorf("failed to read request: %w", err)
return nil, fmt.Errorf("failed to read request: %w", err)
}
if err := req.ParseMultipartForm(1 << 10); err != nil {
return fmt.Errorf("failed to parse multipart form: %w", err)
return nil, fmt.Errorf("failed to parse multipart form: %w", err)
}
if haveValue := req.FormValue(key); haveValue != value {
return fmt.Errorf("header field %q have %q, want %q", key, haveValue, value)
}
return nil
return req, nil
}
func (s *scenario) theBodyInTheRequestToIs(method, path string, value *godog.DocString) error {

View File

@ -0,0 +1,32 @@
Feature: IMAP marks messages as forwarded
Background:
Given there exists an account with username "[user:user]" and password "password"
And the account "[user:user]" has the following custom mailboxes:
| name | type |
| mbox | folder |
And the address "[user:user]@[domain]" of account "[user:user]" has 1 messages in "Folders/mbox"
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 IMAP client "1"
Then it succeeds
Scenario: Mark message as forwarded
When IMAP client "1" selects "Folders/mbox"
And IMAP client "1" marks message 1 as "forwarded"
And it succeeds
Then IMAP client "1" eventually sees that message at row 1 has the flag "forwarded"
And it succeeds
@ignore-live
Scenario: Mark message as forwarded and then revert
When IMAP client "1" selects "Folders/mbox"
And IMAP client "1" marks message 1 as "forwarded"
And it succeeds
Then IMAP client "1" eventually sees that message at row 1 has the flag "forwarded"
And it succeeds
And IMAP client "1" marks message 1 as "unforwarded"
And it succeeds
Then IMAP client "1" eventually sees that message at row 1 does not have the flag "forwarded"
And it succeeds

View File

@ -241,6 +241,7 @@ Feature: SMTP sending of HTMl messages to Internal recipient
{
"content-type": "application/pgp-keys",
"content-disposition": "attachment",
"transfer-encoding": "base64"
}
]
@ -763,7 +764,7 @@ Feature: SMTP sending of HTMl messages to Internal recipient
"content-disposition": "attachment",
"content-disposition-filename": "index.html",
"transfer-encoding": "base64",
"body-is": "PCFET0NUWVBFIGh0bWw+PGh0bWw+PGhlYWQ+Cjx0aXRsZT5QYWdlIFRpdGxlPC90aXRsZT4KPC9o\r\nZWFkPgo8Ym9keT4KCjxoMT5NeSBGaXJzdCBIZWFkaW5nPC9oMT4KPHA+TXkgZmlyc3QgcGFyYWdy\r\nYXBoLjwvcD4KCgogPC9ib2R5PjwvaHRtbD4="
"body-is": "IDwhRE9DVFlQRSBodG1sPg0KPGh0bWw+DQo8aGVhZD4NCjx0aXRsZT5QYWdlIFRpdGxlPC90aXRs\r\nZT4NCjwvaGVhZD4NCjxib2R5Pg0KDQo8aDE+TXkgRmlyc3QgSGVhZGluZzwvaDE+DQo8cD5NeSBm\r\naXJzdCBwYXJhZ3JhcGguPC9wPg0KDQo8L2JvZHk+DQo8L2h0bWw+IA=="
},
{
"content-type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
@ -1070,7 +1071,6 @@ Feature: SMTP sending of HTMl messages to Internal recipient
}
"""
Scenario: Replying to a message after enabling attach public key
When SMTP client "1" sends the following message from "[user:user]@[domain]" to "[user:to]@[domain]":
"""

File diff suppressed because one or more lines are too long

View File

@ -277,4 +277,113 @@ Feature: SMTP send reply
And IMAP client "1" eventually sees the following messages in "INBOX":
| from | subject | in-reply-to | references |
| [user:user2]@[domain] | FW - Please Reply | <something@external.com> | <something@external.com> |
| [user:user2]@[domain] | FW - Please Reply Again | <something@external.com> | <something@external.com> |
| [user:user2]@[domain] | FW - Please Reply Again | <something@external.com> | <something@external.com> |
@long-black
Scenario: Reply with In-Reply-To and X-Forwarded-Message-Id sets forwarded flag
# User1 send the initial message.
When SMTP client "1" sends the following message from "[user:user1]@[domain]" to "[user:user2]@[domain]":
"""
From: Bridge Test <[user:user1]@[domain]>
To: Internal Bridge <[user:user2]@[domain]>
Subject: Please Reply
Message-ID: <something@external.com>
hello
"""
Then it succeeds
Then IMAP client "1" eventually sees the following messages in "Sent":
| from | to | subject | message-id |
| [user:user1]@[domain] | [user:user2]@[domain] | Please Reply | <something@external.com> |
# login user2.
And the user logs in with username "[user:user2]" and password "password"
And user "[user:user2]" connects and authenticates IMAP client "2"
And user "[user:user2]" connects and authenticates SMTP client "2"
And user "[user:user2]" finishes syncing
# User2 receive the message.
Then IMAP client "2" eventually sees the following messages in "INBOX":
| from | subject | message-id | reply-to |
| [user:user1]@[domain] | Please Reply | <something@external.com> | [user:user1]@[domain] |
# User2 reply to it.
When SMTP client "2" sends the following message from "[user:user2]@[domain]" to "[user:user1]@[domain]":
"""
From: Internal Bridge <[user:user2]@[domain]>
To: Bridge Test <[user:user1]@[domain]>
Content-Type: text/plain
Subject: FW - Please Reply
In-Reply-To: <something@external.com>
Message-ID: <something@external.com>
X-Forwarded-Message-Id: <something@external.com>
Heya
"""
Then it succeeds
Then IMAP client "2" eventually sees the following messages in "Sent":
| from | to | subject | in-reply-to | references |
| [user:user2]@[domain] | [user:user1]@[domain] | FW - Please Reply | <something@external.com> | <something@external.com> |
When IMAP client "2" selects "INBOX"
And it succeeds
Then IMAP client "2" eventually sees that message at row 1 has the flag "forwarded"
And it succeeds
Then IMAP client "2" eventually sees that message at row 1 does not have the flag "\Answered"
And it succeeds
# User1 receive the reply.|
And IMAP client "1" eventually sees the following messages in "INBOX":
| from | subject | in-reply-to | references |
| [user:user2]@[domain] | FW - Please Reply | <something@external.com> | <something@external.com> |
@long-black
Scenario: Reply with In-Reply-To sets answered flag
# User1 send the initial message.
When SMTP client "1" sends the following message from "[user:user1]@[domain]" to "[user:user2]@[domain]":
"""
From: Bridge Test <[user:user1]@[domain]>
To: Internal Bridge <[user:user2]@[domain]>
Subject: Please Reply
Message-ID: <something@external.com>
hello
"""
Then it succeeds
Then IMAP client "1" eventually sees the following messages in "Sent":
| from | to | subject | message-id |
| [user:user1]@[domain] | [user:user2]@[domain] | Please Reply | <something@external.com> |
# login user2.
And the user logs in with username "[user:user2]" and password "password"
And user "[user:user2]" connects and authenticates IMAP client "2"
And user "[user:user2]" connects and authenticates SMTP client "2"
And user "[user:user2]" finishes syncing
# User2 receive the message.
Then IMAP client "2" eventually sees the following messages in "INBOX":
| from | subject | message-id | reply-to |
| [user:user1]@[domain] | Please Reply | <something@external.com> | [user:user1]@[domain] |
# User2 reply to it.
When SMTP client "2" sends the following message from "[user:user2]@[domain]" to "[user:user1]@[domain]":
"""
From: Internal Bridge <[user:user2]@[domain]>
To: Bridge Test <[user:user1]@[domain]>
Content-Type: text/plain
Subject: FW - Please Reply
In-Reply-To: <something@external.com>
Message-ID: <something@external.com>
Heya
"""
Then it succeeds
Then IMAP client "2" eventually sees the following messages in "Sent":
| from | to | subject | in-reply-to | references |
| [user:user2]@[domain] | [user:user1]@[domain] | FW - Please Reply | <something@external.com> | <something@external.com> |
When IMAP client "2" selects "INBOX"
And it succeeds
Then IMAP client "2" eventually sees that message at row 1 has the flag "\Answered"
And it succeeds
Then IMAP client "2" eventually sees that message at row 1 does not have the flag "forwarded"
And it succeeds
# User1 receive the reply.|
And IMAP client "1" eventually sees the following messages in "INBOX":
| from | subject | in-reply-to | references |
| [user:user2]@[domain] | FW - Please Reply | <something@external.com> | <something@external.com> |

View File

@ -12,8 +12,17 @@ Feature: The user reports a problem
Then the header in the "POST" multipart request to "/core/v4/reports/bug" has "Title" set to "[Bridge] Bug - title"
And the header in the "POST" multipart request to "/core/v4/reports/bug" has "Description" set to "description"
And the header in the "POST" multipart request to "/core/v4/reports/bug" has "Username" set to "[user:user]"
And the header in the "POST" multipart request to "/core/v4/reports/bug" has "Attachment" set to ""
And the header in the "POST" multipart request to "/core/v4/reports/bug" has no file "logs.zip"
Scenario: User sends a problem report with logs attached
When the user reports a bug with field "IncludeLogs" set to "true"
Then it succeeds
And the header in the "POST" multipart request to "/core/v4/reports/bug" has "Title" set to "[Bridge] Bug - title"
And the header in the "POST" multipart request to "/core/v4/reports/bug" has "Description" set to "description"
And the header in the "POST" multipart request to "/core/v4/reports/bug" has "Username" set to "[user:user]"
And the header in the "POST" multipart request to "/core/v4/reports/bug" has file "logs.zip"
@regression
Scenario: User sends a problem report while signed out of Bridge
When user "[user:user]" logs out
@ -41,7 +50,8 @@ Feature: The user reports a problem
"Description": "Testing Description",
"Username": "[user:user]",
"Email": "[user:user]@[domain]",
"EmailClient": "Apple Mail"
"EmailClient": "Apple Mail",
"IncludeLogs": true
}
"""
Then the header in the "POST" multipart request to "/core/v4/reports/bug" has "Title" set to "[Bridge] Bug - Testing Title"
@ -51,3 +61,4 @@ Feature: The user reports a problem
And the header in the "POST" multipart request to "/core/v4/reports/bug" has "Username" set to "[user:user]"
And the header in the "POST" multipart request to "/core/v4/reports/bug" has "Email" set to "[user:user]@[domain]"
And the header in the "POST" multipart request to "/core/v4/reports/bug" has "Client" set to "Apple Mail"
And the header in the "POST" multipart request to "/core/v4/reports/bug" has file "logs.zip"

View File

@ -468,10 +468,12 @@ func (s *scenario) imapClientMarksAllMessagesAsState(clientID, messageState stri
return nil
}
func (s *scenario) imapClientSeesThatMessageHasTheFlag(clientID string, seq int, flag string) error {
_, client := s.t.getIMAPClient(clientID)
func (s *scenario) imapClientEventuallySeesThatMessageHasTheFlag(clientID string, seq int, flag string) error {
return eventually(func() error {
_, client := s.t.getIMAPClient(clientID)
return clientIsFlagApplied(client, seq, flag, true, false)
return clientIsFlagApplied(client, seq, flag, true, false)
})
}
func (s *scenario) imapClientSeesThatMessageDoesNotHaveTheFlag(clientID string, seq int, flag string) error {
@ -480,38 +482,46 @@ func (s *scenario) imapClientSeesThatMessageDoesNotHaveTheFlag(clientID string,
return clientIsFlagApplied(client, seq, flag, false, false)
}
func (s *scenario) imapClientSeesThatTheMessageWithSubjectHasTheFlag(clientID, subject, flag string) error {
_, client := s.t.getIMAPClient(clientID)
func (s *scenario) imapClientEventuallySeesThatTheMessageWithSubjectHasTheFlag(clientID, subject, flag string) error {
return eventually(func() error {
_, client := s.t.getIMAPClient(clientID)
uid, err := clientGetUIDBySubject(client, client.Mailbox().Name, subject)
if err != nil {
return err
}
uid, err := clientGetUIDBySubject(client, client.Mailbox().Name, subject)
if err != nil {
return err
}
return clientIsFlagApplied(client, int(uid), flag, true, false)
return clientIsFlagApplied(client, int(uid), flag, true, false)
})
}
func (s *scenario) imapClientSeesThatTheMessageWithSubjectDoesNotHaveTheFlag(clientID, subject, flag string) error {
_, client := s.t.getIMAPClient(clientID)
func (s *scenario) imapClientEventuallySeesThatTheMessageWithSubjectDoesNotHaveTheFlag(clientID, subject, flag string) error {
return eventually(func() error {
_, client := s.t.getIMAPClient(clientID)
uid, err := clientGetUIDBySubject(client, client.Mailbox().Name, subject)
if err != nil {
return err
}
uid, err := clientGetUIDBySubject(client, client.Mailbox().Name, subject)
if err != nil {
return err
}
return clientIsFlagApplied(client, int(uid), flag, false, false)
return clientIsFlagApplied(client, int(uid), flag, false, false)
})
}
func (s *scenario) imapClientSeesThatAllTheMessagesHaveTheFlag(clientID string, flag string) error {
_, client := s.t.getIMAPClient(clientID)
func (s *scenario) imapClientEventuallySeesThatAllTheMessagesHaveTheFlag(clientID string, flag string) error {
return eventually(func() error {
_, client := s.t.getIMAPClient(clientID)
return clientIsFlagApplied(client, 1, flag, true, true)
return clientIsFlagApplied(client, 1, flag, true, true)
})
}
func (s *scenario) imapClientSeesThatAllTheMessagesDoNotHaveTheFlag(clientID string, flag string) error {
_, client := s.t.getIMAPClient(clientID)
func (s *scenario) imapClientEventuallySeesThatAllTheMessagesDoNotHaveTheFlag(clientID string, flag string) error {
return eventually(func() error {
_, client := s.t.getIMAPClient(clientID)
return clientIsFlagApplied(client, 1, flag, false, true)
return clientIsFlagApplied(client, 1, flag, false, true)
})
}
func (s *scenario) imapClientExpunges(clientID string) error {
@ -916,6 +926,18 @@ func clientChangeMessageState(client *client.Client, seq int, messageState strin
if err != nil {
return err
}
case messageState == "forwarded":
_, err := clientStore(client, seq, seq, isUID, imap.FormatFlagsOp(imap.AddFlags, true), "Forwarded")
if err != nil {
return err
}
case messageState == "unforwarded":
_, err := clientStore(client, seq, seq, isUID, imap.FormatFlagsOp(imap.RemoveFlags, true), "Forwarded")
if err != nil {
return err
}
}
return nil

View File

@ -29,6 +29,8 @@ func (s *scenario) steps(ctx *godog.ScenarioContext) {
ctx.Step(`^the user agent is "([^"]*)"$`, s.theUserAgentIs)
ctx.Step(`^the header in the "([^"]*)" request to "([^"]*)" has "([^"]*)" set to "([^"]*)"$`, s.theHeaderInTheRequestToHasSetTo)
ctx.Step(`^the header in the "([^"]*)" multipart request to "([^"]*)" has "([^"]*)" set to "([^"]*)"$`, s.theHeaderInTheMultipartRequestToHasSetTo)
ctx.Step(`^the header in the "([^"]*)" multipart request to "([^"]*)" has file "([^"]*)"$`, s.theHeaderInTheMultipartRequestToHasFile)
ctx.Step(`^the header in the "([^"]*)" multipart request to "([^"]*)" has no file "([^"]*)"$`, s.theHeaderInTheMultipartRequestToHasNoFile)
ctx.Step(`^the body in the "([^"]*)" request to "([^"]*)" is:$`, s.theBodyInTheRequestToIs)
ctx.Step(`^the body in the "([^"]*)" response to "([^"]*)" is:$`, s.theBodyInTheResponseToIs)
ctx.Step(`^the API requires bridge version at least "([^"]*)"$`, s.theAPIRequiresBridgeVersion)
@ -154,12 +156,12 @@ func (s *scenario) steps(ctx *godog.ScenarioContext) {
ctx.Step(`^IMAP client "([^"]*)" marks message (\d+) as "([^"]*)"$`, s.imapClientMarksMessageAsState)
ctx.Step(`^IMAP client "([^"]*)" marks the message with subject "([^"]*)" as "([^"]*)"$`, s.imapClientMarksTheMessageWithSubjectAsState)
ctx.Step(`^IMAP client "([^"]*)" marks all messages as "([^"]*)"$`, s.imapClientMarksAllMessagesAsState)
ctx.Step(`^IMAP client "([^"]*)" eventually sees that message at row (\d+) has the flag "([^"]*)"$`, s.imapClientSeesThatMessageHasTheFlag)
ctx.Step(`^IMAP client "([^"]*)" eventually sees that message at row (\d+) has the flag "([^"]*)"$`, s.imapClientEventuallySeesThatMessageHasTheFlag)
ctx.Step(`^IMAP client "([^"]*)" eventually sees that message at row (\d+) does not have the flag "([^"]*)"$`, s.imapClientSeesThatMessageDoesNotHaveTheFlag)
ctx.Step(`^IMAP client "([^"]*)" eventually sees that the message with subject "([^"]*)" has the flag "([^"]*)"`, s.imapClientSeesThatTheMessageWithSubjectHasTheFlag)
ctx.Step(`^IMAP client "([^"]*)" eventually sees that the message with subject "([^"]*)" does not have the flag "([^"]*)"`, s.imapClientSeesThatTheMessageWithSubjectDoesNotHaveTheFlag)
ctx.Step(`^IMAP client "([^"]*)" eventually sees that all the messages have the flag "([^"]*)"`, s.imapClientSeesThatAllTheMessagesHaveTheFlag)
ctx.Step(`^IMAP client "([^"]*)" eventually sees that all the messages do not have the flag "([^"]*)"`, s.imapClientSeesThatAllTheMessagesDoNotHaveTheFlag)
ctx.Step(`^IMAP client "([^"]*)" eventually sees that the message with subject "([^"]*)" has the flag "([^"]*)"`, s.imapClientEventuallySeesThatTheMessageWithSubjectHasTheFlag)
ctx.Step(`^IMAP client "([^"]*)" eventually sees that the message with subject "([^"]*)" does not have the flag "([^"]*)"`, s.imapClientEventuallySeesThatTheMessageWithSubjectDoesNotHaveTheFlag)
ctx.Step(`^IMAP client "([^"]*)" eventually sees that all the messages have the flag "([^"]*)"`, s.imapClientEventuallySeesThatAllTheMessagesHaveTheFlag)
ctx.Step(`^IMAP client "([^"]*)" eventually sees that all the messages do not have the flag "([^"]*)"`, s.imapClientEventuallySeesThatAllTheMessagesDoNotHaveTheFlag)
ctx.Step(`^IMAP client "([^"]*)" appends the following message to "([^"]*)":$`, s.imapClientAppendsTheFollowingMessageToMailbox)
ctx.Step(`^IMAP client "([^"]*)" appends the following messages to "([^"]*)":$`, s.imapClientAppendsTheFollowingMessagesToMailbox)
ctx.Step(`^IMAP client "([^"]*)" appends "([^"]*)" to "([^"]*)"$`, s.imapClientAppendsToMailbox)

View File

@ -28,6 +28,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/app"
"github.com/ProtonMail/proton-bridge/v3/internal/locations"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/pkg/keychain"
"github.com/urfave/cli/v2"
)
@ -50,27 +51,31 @@ func main() {
func readAction(c *cli.Context) error {
return app.WithLocations(func(locations *locations.Locations) error {
return app.WithVault(locations, async.NoopPanicHandler{}, func(vault *vault.Vault, insecure, corrupt bool) error {
if _, err := os.Stdout.Write(vault.ExportJSON()); err != nil {
return fmt.Errorf("failed to write vault: %w", err)
}
return app.WithKeychainList(func(keychains *keychain.List) error {
return app.WithVault(locations, keychains, async.NoopPanicHandler{}, func(vault *vault.Vault, insecure, corrupt bool) error {
if _, err := os.Stdout.Write(vault.ExportJSON()); err != nil {
return fmt.Errorf("failed to write vault: %w", err)
}
return nil
return nil
})
})
})
}
func writeAction(c *cli.Context) error {
return app.WithLocations(func(locations *locations.Locations) error {
return app.WithVault(locations, async.NoopPanicHandler{}, func(vault *vault.Vault, insecure, corrupt bool) error {
b, err := io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("failed to read vault: %w", err)
}
return app.WithKeychainList(func(keychains *keychain.List) error {
return app.WithVault(locations, keychains, async.NoopPanicHandler{}, func(vault *vault.Vault, insecure, corrupt bool) error {
b, err := io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("failed to read vault: %w", err)
}
vault.ImportJSON(b)
vault.ImportJSON(b)
return nil
return nil
})
})
})
}