From 17f4d6097a063c78f16e6a31e41e0e7ba753228e Mon Sep 17 00:00:00 2001 From: Jakub Date: Wed, 8 Apr 2020 12:59:16 +0200 Subject: [PATCH] We build too many walls and not enough bridges --- .gitattributes | 1 + .gitignore | 25 + .gitlab-ci.yml | 109 ++ .gitmodules | 0 .golangci.yml | 66 + BUILDS.md | 38 + CONTRIBUTING.md | 11 + COPYING.md | 73 ++ Changelog.md | 788 ++++++++++++ LICENSE | 621 ++++++++++ Makefile | 208 ++++ README.md | 52 + ci/Dockerfile | 4 + cmd/Desktop-Bridge/main.go | 434 +++++++ doc/bridge.md | 135 ++ doc/communication.md | 114 ++ doc/database.md | 27 + doc/encryption.md | 12 + doc/index.md | 9 + go.mod | 77 ++ go.sum | 228 ++++ icon.iconset/icon_128x128.png | Bin 0 -> 3433 bytes icon.iconset/icon_128x128@2x.png | Bin 0 -> 6986 bytes icon.iconset/icon_16x16.png | Bin 0 -> 945 bytes icon.iconset/icon_16x16@2x.png | Bin 0 -> 1085 bytes icon.iconset/icon_256x256.png | Bin 0 -> 6986 bytes icon.iconset/icon_256x256@2x.png | Bin 0 -> 14902 bytes icon.iconset/icon_32x32.png | Bin 0 -> 1085 bytes icon.iconset/icon_32x32@2x.png | Bin 0 -> 1817 bytes icon.iconset/icon_512x512.png | Bin 0 -> 14902 bytes icon.iconset/icon_512x512@2x.png | Bin 0 -> 25720 bytes internal/api/api.go | 94 ++ internal/api/ctx.go | 51 + internal/api/focus.go | 55 + internal/bridge/bridge.go | 510 ++++++++ internal/bridge/bridge_login_test.go | 233 ++++ internal/bridge/bridge_new_test.go | 162 +++ internal/bridge/bridge_test.go | 256 ++++ internal/bridge/bridge_users_test.go | 121 ++ internal/bridge/constants.go | 23 + internal/bridge/credentials/credentials.go | 137 +++ internal/bridge/credentials/crypto.go | 39 + internal/bridge/credentials/store.go | 316 +++++ internal/bridge/credentials/store_test.go | 297 +++++ internal/bridge/credits.go | 22 + internal/bridge/mock_listener.go | 107 ++ internal/bridge/mocks/mocks.go | 923 ++++++++++++++ internal/bridge/release_notes.go | 34 + internal/bridge/types.go | 105 ++ internal/bridge/user.go | 621 ++++++++++ internal/bridge/user_credentials_test.go | 209 ++++ internal/bridge/user_new_test.go | 188 +++ internal/bridge/user_test.go | 113 ++ internal/bridge/useragent.go | 41 + internal/bridge/useragent_test.go | 51 + internal/events/events.go | 53 + internal/frontend/autoconfig/applemail.go | 108 ++ internal/frontend/autoconfig/autoconfig.go | 33 + internal/frontend/cli/account_utils.go | 100 ++ internal/frontend/cli/accounts.go | 219 ++++ internal/frontend/cli/frontend.go | 264 ++++ internal/frontend/cli/system.go | 164 +++ internal/frontend/cli/updates.go | 65 + internal/frontend/cli/utils.go | 123 ++ internal/frontend/frontend.go | 87 ++ .../frontend/qml/BridgeUI/AccountDelegate.qml | 430 +++++++ internal/frontend/qml/BridgeUI/BubbleMenu.qml | 72 ++ internal/frontend/qml/BridgeUI/Credits.qml | 49 + .../qml/BridgeUI/DialogFirstStart.qml | 124 ++ .../qml/BridgeUI/DialogPortChange.qml | 233 ++++ .../qml/BridgeUI/DialogTLSCertInfo.qml | 77 ++ .../frontend/qml/BridgeUI/DialogYesNo.qml | 382 ++++++ internal/frontend/qml/BridgeUI/HelpView.qml | 134 ++ internal/frontend/qml/BridgeUI/InfoWindow.qml | 144 +++ internal/frontend/qml/BridgeUI/MainWindow.qml | 455 +++++++ .../frontend/qml/BridgeUI/ManualWindow.qml | 16 + .../qml/BridgeUI/OutgoingNoEncPopup.qml | 148 +++ .../frontend/qml/BridgeUI/SettingsView.qml | 180 +++ .../frontend/qml/BridgeUI/StatusFooter.qml | 16 + .../frontend/qml/BridgeUI/VersionInfo.qml | 127 ++ internal/frontend/qml/BridgeUI/qmldir | 15 + internal/frontend/qml/Gui.qml | 314 +++++ .../qml/ProtonUI/AccessibleButton.qml | 34 + .../qml/ProtonUI/AccessibleSelectableText.qml | 40 + .../frontend/qml/ProtonUI/AccessibleText.qml | 40 + .../frontend/qml/ProtonUI/AccountView.qml | 140 +++ .../frontend/qml/ProtonUI/AddAccountBar.qml | 69 ++ internal/frontend/qml/ProtonUI/BubbleNote.qml | 170 +++ .../frontend/qml/ProtonUI/BugReportWindow.qml | 337 +++++ .../frontend/qml/ProtonUI/ButtonIconText.qml | 100 ++ .../frontend/qml/ProtonUI/ButtonRounded.qml | 92 ++ .../frontend/qml/ProtonUI/CheckBoxLabel.qml | 55 + .../frontend/qml/ProtonUI/ClickIconText.qml | 98 ++ internal/frontend/qml/ProtonUI/Dialog.qml | 147 +++ .../frontend/qml/ProtonUI/DialogAddUser.qml | 464 +++++++ .../ProtonUI/DialogConnectionTroubleshoot.qml | 148 +++ .../frontend/qml/ProtonUI/DialogUpdate.qml | 250 ++++ .../qml/ProtonUI/FileAndFolderSelect.qml | 78 ++ .../frontend/qml/ProtonUI/InfoToolTip.qml | 101 ++ .../frontend/qml/ProtonUI/InformationBar.qml | 233 ++++ internal/frontend/qml/ProtonUI/InputBox.qml | 78 ++ internal/frontend/qml/ProtonUI/InputField.qml | 172 +++ .../qml/ProtonUI/InstanceExistsWindow.qml | 79 ++ internal/frontend/qml/ProtonUI/LogoHeader.qml | 150 +++ .../frontend/qml/ProtonUI/PopupMessage.qml | 81 ++ .../frontend/qml/ProtonUI/ProgressBar.qml | 16 + internal/frontend/qml/ProtonUI/Style.qml | 1089 +++++++++++++++++ .../qml/ProtonUI/TLSCertPinIssueBar.qml | 69 ++ internal/frontend/qml/ProtonUI/TabButton.qml | 103 ++ internal/frontend/qml/ProtonUI/TabLabels.qml | 107 ++ internal/frontend/qml/ProtonUI/TextLabel.qml | 45 + internal/frontend/qml/ProtonUI/TextValue.qml | 85 ++ .../frontend/qml/ProtonUI/WindowTitleBar.qml | 348 ++++++ internal/frontend/qml/ProtonUI/qmldir | 31 + internal/frontend/qml/tst_Gui.qml | 611 +++++++++ internal/frontend/qt/Makefile.local | 64 + internal/frontend/qt/accountModel.go | 240 ++++ internal/frontend/qt/accounts.go | 213 ++++ internal/frontend/qt/frontend.go | 645 ++++++++++ internal/frontend/qt/frontend_nogui.go | 59 + internal/frontend/qt/helpers.go | 84 ++ internal/frontend/qt/logs.cpp | 23 + internal/frontend/qt/logs.go | 38 + internal/frontend/qt/logs.h | 20 + internal/frontend/qt/notification.go | 33 + internal/frontend/qt/resources.qrc | 77 ++ internal/frontend/qt/systray.go | 117 ++ internal/frontend/qt/translate.ts | 669 ++++++++++ internal/frontend/qt/ui.go | 192 +++ .../frontend/share/fontawesome-webfont.ttf | Bin 0 -> 165548 bytes internal/frontend/share/icon.rc | 1 + internal/frontend/share/icons/Bridge.icns | Bin 0 -> 266570 bytes internal/frontend/share/icons/all_icons.svg | 541 ++++++++ .../frontend/share/icons/black-syserror.png | Bin 0 -> 34901 bytes .../frontend/share/icons/black-systray.png | Bin 0 -> 28218 bytes .../frontend/share/icons/black-syswarn.png | Bin 0 -> 38832 bytes internal/frontend/share/icons/export.sh | 17 + internal/frontend/share/icons/logo.ico | Bin 0 -> 569990 bytes internal/frontend/share/icons/logo.svg | 31 + internal/frontend/share/icons/macos_gray.png | Bin 0 -> 849 bytes internal/frontend/share/icons/macos_green.png | Bin 0 -> 956 bytes .../frontend/share/icons/macos_green_dark.png | Bin 0 -> 1227 bytes .../frontend/share/icons/macos_green_hl.png | Bin 0 -> 1128 bytes internal/frontend/share/icons/macos_red.png | Bin 0 -> 1012 bytes .../frontend/share/icons/macos_red_dark.png | Bin 0 -> 1486 bytes .../frontend/share/icons/macos_red_hl.png | Bin 0 -> 1452 bytes .../frontend/share/icons/macos_yellow.png | Bin 0 -> 997 bytes .../share/icons/macos_yellow_dark.png | Bin 0 -> 1119 bytes .../frontend/share/icons/macos_yellow_hl.png | Bin 0 -> 1041 bytes internal/frontend/share/icons/pm_logo.png | Bin 0 -> 5442 bytes .../frontend/share/icons/rectangle-app.png | Bin 0 -> 23733 bytes .../share/icons/rectangle-systray.png | Bin 0 -> 23551 bytes .../share/icons/rectangle-syswarn.png | Bin 0 -> 33799 bytes internal/frontend/share/icons/rounded-app.png | Bin 0 -> 25720 bytes internal/frontend/share/icons/rounded-app.svg | 104 ++ .../frontend/share/icons/rounded-systray.png | Bin 0 -> 25785 bytes .../frontend/share/icons/rounded-syswarn.png | Bin 0 -> 35766 bytes .../frontend/share/icons/white-syserror.png | Bin 0 -> 37648 bytes .../frontend/share/icons/white-systray.png | Bin 0 -> 24506 bytes .../frontend/share/icons/white-syswarn.png | Bin 0 -> 42850 bytes internal/frontend/share/icons/win10_Dash.png | Bin 0 -> 147 bytes internal/frontend/share/icons/win10_Times.png | Bin 0 -> 412 bytes internal/frontend/types/types.go | 94 ++ internal/imap/backend.go | 220 ++++ internal/imap/backend_cache.go | 136 ++ internal/imap/bridge.go | 78 ++ internal/imap/cache/cache.go | 151 +++ internal/imap/cache/cache_test.go | 99 ++ internal/imap/imap.go | 35 + internal/imap/mailbox.go | 187 +++ internal/imap/mailbox_message.go | 784 ++++++++++++ internal/imap/mailbox_message_test.go | 41 + internal/imap/mailbox_messages.go | 490 ++++++++ internal/imap/mailbox_root.go | 120 ++ internal/imap/server.go | 188 +++ internal/imap/store.go | 156 +++ internal/imap/uidplus/extension.go | 198 +++ internal/imap/uidplus/extension_test.go | 108 ++ internal/imap/user.go | 239 ++++ internal/imap/utils.go | 32 + internal/metrics/metrics.go | 70 ++ internal/pmapifactory/pmapi_noprod.go | 33 + internal/pmapifactory/pmapi_prod.go | 54 + internal/preferences/preferences.go | 78 ++ internal/smtp/backend.go | 109 ++ internal/smtp/bridge.go | 65 + internal/smtp/send_recorder.go | 124 ++ internal/smtp/send_recorder_test.go | 414 +++++++ internal/smtp/sending_info.go | 244 ++++ internal/smtp/sending_info_test.go | 604 +++++++++ internal/smtp/server.go | 112 ++ internal/smtp/smtp.go | 25 + internal/smtp/store.go | 36 + internal/smtp/user.go | 513 ++++++++ internal/smtp/utils.go | 96 ++ internal/smtp/vcard_tools.go | 94 ++ internal/store/address.go | 109 ++ internal/store/address_mailbox.go | 106 ++ internal/store/address_message.go | 42 + internal/store/cache.go | 114 ++ internal/store/change.go | 109 ++ internal/store/change_test.go | 129 ++ internal/store/convert.go | 32 + internal/store/event_loop.go | 546 +++++++++ internal/store/event_loop_test.go | 153 +++ internal/store/mailbox.go | 265 ++++ internal/store/mailbox_counts.go | 257 ++++ internal/store/mailbox_counts_test.go | 126 ++ internal/store/mailbox_ids.go | 263 ++++ internal/store/mailbox_ids_test.go | 147 +++ internal/store/mailbox_message.go | 375 ++++++ internal/store/main_test.go | 31 + internal/store/message.go | 108 ++ internal/store/mocks/mocks.go | 193 +++ internal/store/mocks/utils_mocks.go | 106 ++ internal/store/store.go | 396 ++++++ internal/store/store_address_mode.go | 112 ++ internal/store/store_structure_version.go | 68 + internal/store/store_test.go | 129 ++ internal/store/store_test_exports.go | 145 +++ internal/store/sync.go | 222 ++++ internal/store/sync_state.go | 217 ++++ internal/store/sync_state_test.go | 85 ++ internal/store/sync_test.go | 509 ++++++++ internal/store/types.go | 69 ++ internal/store/ulimit.go | 64 + internal/store/user.go | 41 + internal/store/user_address.go | 216 ++++ internal/store/user_address_info.go | 158 +++ internal/store/user_mailbox.go | 229 ++++ internal/store/user_message.go | 329 +++++ internal/store/user_message_test.go | 156 +++ internal/store/user_sync.go | 247 ++++ internal/store/user_sync_test.go | 90 ++ pkg/algo/algo.go | 19 + pkg/algo/sets.go | 47 + pkg/algo/sets_test.go | 71 ++ pkg/args/args.go | 35 + pkg/config/config.go | 259 ++++ pkg/config/config_test.go | 238 ++++ pkg/config/logs.go | 252 ++++ pkg/config/logs_all.go | 49 + pkg/config/logs_qa.go | 50 + pkg/config/logs_test.go | 225 ++++ pkg/config/mock_config.go | 76 ++ pkg/config/preferences.go | 127 ++ pkg/config/preferences_test.go | 109 ++ pkg/config/tls.go | 170 +++ pkg/config/tls_test.go | 63 + pkg/connection/check_connection.go | 88 ++ pkg/connection/check_connection_test.go | 91 ++ pkg/dialer/dial_client.go | 46 + pkg/keychain/keychain.go | 131 ++ pkg/keychain/keychain_darwin.go | 140 +++ pkg/keychain/keychain_linux.go | 73 ++ pkg/keychain/keychain_test.go | 152 +++ pkg/keychain/keychain_windows.go | 40 + pkg/listener/listener.go | 180 +++ pkg/listener/listener_test.go | 172 +++ pkg/message/address.go | 56 + pkg/message/body.go | 75 ++ pkg/message/envelope.go | 48 + pkg/message/flags.go | 83 ++ pkg/message/header.go | 214 ++++ pkg/message/html.go | 71 ++ pkg/message/message.go | 188 +++ pkg/message/parser.go | 468 +++++++ pkg/message/parser_test.go | 107 ++ pkg/message/section.go | 413 +++++++ pkg/message/section_test.go | 414 +++++++ pkg/mime/Changelog.md | 24 + pkg/mime/encoding.go | 254 ++++ pkg/mime/encoding_test.go | 445 +++++++ pkg/mime/mediaType.go | 364 ++++++ pkg/mime/parser.go | 544 ++++++++ pkg/mime/parser_test.go | 228 ++++ pkg/mime/utf7Decoder.go | 188 +++ pkg/parallel/parallel.go | 136 ++ pkg/parallel/parallel_test.go | 131 ++ pkg/pmapi/Changelog.md | 309 +++++ pkg/pmapi/Makefile | 19 + pkg/pmapi/addresses.go | 204 +++ pkg/pmapi/addresses_test.go | 90 ++ pkg/pmapi/attachments.go | 264 ++++ pkg/pmapi/attachments_test.go | 222 ++++ pkg/pmapi/auth.go | 506 ++++++++ pkg/pmapi/auth_test.go | 366 ++++++ pkg/pmapi/auth_test_export.go | 23 + pkg/pmapi/bugs.go | 217 ++++ pkg/pmapi/bugs_test.go | 180 +++ pkg/pmapi/client.go | 503 ++++++++ pkg/pmapi/client_test.go | 215 ++++ pkg/pmapi/config.go | 43 + pkg/pmapi/config_dev.go | 24 + pkg/pmapi/config_local.go | 37 + pkg/pmapi/config_nopin.go | 25 + pkg/pmapi/conrep.go | 23 + pkg/pmapi/contacts.go | 430 +++++++ pkg/pmapi/contacts_test.go | 677 ++++++++++ pkg/pmapi/conversations.go | 51 + pkg/pmapi/dialer_with_proxy.go | 373 ++++++ pkg/pmapi/dialer_with_proxy_test.go | 126 ++ pkg/pmapi/events.go | 237 ++++ pkg/pmapi/events_test.go | 524 ++++++++ pkg/pmapi/import.go | 157 +++ pkg/pmapi/import_test.go | 155 +++ pkg/pmapi/key.go | 138 +++ pkg/pmapi/keyring.go | 295 +++++ pkg/pmapi/keyring_test.go | 94 ++ pkg/pmapi/labels.go | 177 +++ pkg/pmapi/labels_test.go | 186 +++ pkg/pmapi/messages.go | 810 ++++++++++++ pkg/pmapi/messages_test.go | 223 ++++ pkg/pmapi/metrics.go | 43 + pkg/pmapi/metrics_test.go | 43 + pkg/pmapi/passwords.go | 47 + pkg/pmapi/pmapi_test.go | 62 + pkg/pmapi/proxy.go | 304 +++++ pkg/pmapi/proxy_test.go | 304 +++++ pkg/pmapi/req.go | 90 ++ pkg/pmapi/res.go | 72 ++ pkg/pmapi/sentry.go | 173 +++ pkg/pmapi/sentry_test.go | 65 + pkg/pmapi/server_test.go | 166 +++ pkg/pmapi/settings.go | 118 ++ .../keyring_addressKeysPrimaryHasToken_JSON | 22 + .../keyring_addressKeysSecondaryHasToken_JSON | 22 + .../keyring_addressKeysWithTokens_JSON | 12 + .../keyring_addressKeysWithoutTokens_JSON | 12 + pkg/pmapi/testdata/keyring_userKey | 62 + pkg/pmapi/testdata/keyring_userKey_JSON | 10 + pkg/pmapi/testdata/routes/HTTP_200.json | 3 + pkg/pmapi/testdata/routes/HTTP_401.json | 4 + pkg/pmapi/testdata/routes/HTTP_402.json | 4 + .../routes/addresses/get_response.json | 43 + .../auth/2fa/post_401_bad_password.json | 5 + .../auth/2fa/post_422_bad_password.json | 5 + .../routes/auth/2fa/post_response.json | 4 + .../testdata/routes/auth/delete_response.json | 4 + .../testdata/routes/auth/enc_priv_key.asc | 65 + .../routes/auth/encrypted_access_token.asc | 15 + .../routes/auth/info/post_response.json | 13 + .../testdata/routes/auth/post_response.json | 11 + .../auth/refresh/post_resp_has_uid.json | 10 + .../routes/auth/refresh/post_response.json | 8 + .../routes/contacts/put_response.json | 21 + .../routes/keys/salts/get_response.json | 13 + .../routes/messages/get_response.json | 55 + .../routes/messages/label/put_response.json | 17 + .../testdata/routes/users/get_response.json | 27 + pkg/pmapi/testdata/symmetric_key.json | 4 + pkg/pmapi/testdata/testPrivateKey | 63 + pkg/pmapi/testdata/testPrivateKeyLegacy | 63 + pkg/pmapi/testdata/testPublicKey | 33 + pkg/pmapi/users.go | 130 ++ pkg/pmapi/users_test.go | 97 ++ pkg/ports/ports.go | 65 + pkg/ports/ports_test.go | 56 + pkg/srp/hash.go | 107 ++ pkg/srp/srp.go | 219 ++++ pkg/srp/srp_test.go | 111 ++ pkg/updates/bridge_pubkey.gpg | 53 + pkg/updates/compare_versions.go | 102 ++ pkg/updates/compare_versions_test.go | 78 ++ pkg/updates/downloader.go | 131 ++ pkg/updates/progress.go | 50 + pkg/updates/signature.go | 108 ++ pkg/updates/sync.go | 239 ++++ pkg/updates/sync_test.go | 157 +++ pkg/updates/tar.go | 126 ++ .../testdata/current_version_linux.json | 1 + .../testdata/current_version_linux.json.sig | Bin 0 -> 566 bytes pkg/updates/updates.go | 310 +++++ pkg/updates/updates_beta.go | 25 + pkg/updates/updates_qa.go | 26 + pkg/updates/updates_test.go | 178 +++ pkg/updates/version_info.go | 49 + pkg/useragent/useragent.go | 57 + pkg/useragent/useragent_test.go | 57 + release-notes/bugs.txt | 2 + release-notes/notes.txt | 8 + test/Makefile | 41 + test/README.md | 130 ++ test/accounts/account.go | 192 +++ test/accounts/accounts.go | 79 ++ test/accounts/fake.json | 105 ++ test/api_checks_test.go | 79 ++ test/bdd_test.go | 66 + test/benchmarks/bench_results/human-table.py | 104 ++ test/benchmarks/bench_test.go | 201 +++ test/bridge_actions_test.go | 134 ++ test/bridge_checks_test.go | 198 +++ test/bridge_setup_test.go | 139 +++ test/context/accounts.go | 57 + test/context/bddt.go | 42 + test/context/bridge.go | 84 ++ test/context/bridge_panic_handler.go | 49 + test/context/bridge_user.go | 148 +++ test/context/cleaner.go | 87 ++ test/context/config.go | 91 ++ test/context/context.go | 118 ++ test/context/credentials.go | 102 ++ test/context/environments.go | 40 + test/context/imap.go | 80 ++ test/context/pmapi_controller.go | 84 ++ test/context/smtp.go | 81 ++ test/context/utils.go | 87 ++ test/fakeapi/attachments.go | 47 + test/fakeapi/auth.go | 153 +++ test/fakeapi/contacts.go | 50 + test/fakeapi/controller.go | 71 ++ test/fakeapi/controller_calls.go | 93 ++ test/fakeapi/controller_control.go | 142 +++ test/fakeapi/controller_session.go | 57 + test/fakeapi/controller_user.go | 37 + test/fakeapi/counts.go | 60 + test/fakeapi/events.go | 105 ++ test/fakeapi/fakeapi.go | 158 +++ test/fakeapi/idgenerator.go | 31 + test/fakeapi/keys.go | 65 + test/fakeapi/labels.go | 90 ++ test/fakeapi/messages.go | 374 ++++++ test/fakeapi/reports.go | 44 + test/fakeapi/user.go | 61 + test/fakeapi/utils.go | 46 + test/features/bridge/addressmode.feature | 46 + test/features/bridge/deleteuser.feature | 36 + test/features/bridge/login.feature | 66 + test/features/bridge/relogin.feature | 35 + test/features/bridge/start.feature | 78 ++ test/features/bridge/sync.feature | 60 + test/features/imap/auth.feature | 88 ++ test/features/imap/idle/basic.feature | 62 + test/features/imap/idle/two_users.feature | 28 + test/features/imap/mailbox/create.feature | 25 + test/features/imap/mailbox/delete.feature | 29 + test/features/imap/mailbox/info.feature | 15 + test/features/imap/mailbox/list.feature | 16 + test/features/imap/mailbox/rename.feature | 30 + test/features/imap/mailbox/select.feature | 17 + test/features/imap/mailbox/status.feature | 21 + test/features/imap/message/copy.feature | 45 + test/features/imap/message/create.feature | 59 + test/features/imap/message/delete.feature | 41 + test/features/imap/message/fetch.feature | 51 + test/features/imap/message/move.feature | 54 + test/features/imap/message/search.feature | 29 + test/features/imap/message/update.feature | 35 + test/features/smtp/auth.feature | 65 + test/features/smtp/send/bcc.feature | 69 ++ test/features/smtp/send/failures.feature | 56 + test/features/smtp/send/html.feature | 297 +++++ test/features/smtp/send/html_att.feature | 122 ++ test/features/smtp/send/plain.feature | 218 ++++ test/features/smtp/send/plain_att.feature | 187 +++ test/features/smtp/send/same_message.feature | 45 + test/features/smtp/send/two_messages.feature | 71 ++ test/imap_actions_auth_test.go | 82 ++ test/imap_actions_mailbox_test.go | 74 ++ test/imap_actions_messages_test.go | 158 +++ test/imap_checks_test.go | 122 ++ test/imap_setup_test.go | 66 + test/internal_error.go | 28 + test/liveapi/calls.go | 75 ++ test/liveapi/cleanup.go | 132 ++ test/liveapi/controller.go | 62 + test/liveapi/labels.go | 105 ++ test/liveapi/messages.go | 134 ++ test/liveapi/transport.go | 59 + test/liveapi/users.go | 66 + test/main_test.go | 50 + test/mocks/debug.go | 78 ++ test/mocks/imap.go | 227 ++++ test/mocks/imap_response.go | 199 +++ test/mocks/smtp.go | 182 +++ test/mocks/smtp_response.go | 42 + test/mocks/testingt.go | 23 + test/mocks/utils.go | 26 + test/smtp_actions_test.go | 114 ++ test/smtp_checks_test.go | 41 + test/smtp_setup_test.go | 55 + test/store_actions_test.go | 39 + test/store_checks_test.go | 339 +++++ test/store_setup_test.go | 189 +++ test/testdata/address_key.json | 12 + test/testdata/user_key.json | 10 + utils/addcert.scpt | Bin 0 -> 778 bytes utils/credits.sh | 33 + utils/dxtn/dxtn.cbp | 52 + utils/dxtn/dxtn.layout | 10 + utils/dxtn/main.cpp | 6 + utils/dxtn/main.h | 12 + utils/release-notes.sh | 21 + utils/sync_bench.py | 76 ++ 494 files changed, 62753 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 .gitmodules create mode 100644 .golangci.yml create mode 100644 BUILDS.md create mode 100644 CONTRIBUTING.md create mode 100644 COPYING.md create mode 100644 Changelog.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 ci/Dockerfile create mode 100644 cmd/Desktop-Bridge/main.go create mode 100644 doc/bridge.md create mode 100644 doc/communication.md create mode 100644 doc/database.md create mode 100644 doc/encryption.md create mode 100644 doc/index.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 icon.iconset/icon_128x128.png create mode 100644 icon.iconset/icon_128x128@2x.png create mode 100644 icon.iconset/icon_16x16.png create mode 100644 icon.iconset/icon_16x16@2x.png create mode 100644 icon.iconset/icon_256x256.png create mode 100644 icon.iconset/icon_256x256@2x.png create mode 100644 icon.iconset/icon_32x32.png create mode 100644 icon.iconset/icon_32x32@2x.png create mode 100644 icon.iconset/icon_512x512.png create mode 100644 icon.iconset/icon_512x512@2x.png create mode 100644 internal/api/api.go create mode 100644 internal/api/ctx.go create mode 100644 internal/api/focus.go create mode 100644 internal/bridge/bridge.go create mode 100644 internal/bridge/bridge_login_test.go create mode 100644 internal/bridge/bridge_new_test.go create mode 100644 internal/bridge/bridge_test.go create mode 100644 internal/bridge/bridge_users_test.go create mode 100644 internal/bridge/constants.go create mode 100644 internal/bridge/credentials/credentials.go create mode 100644 internal/bridge/credentials/crypto.go create mode 100644 internal/bridge/credentials/store.go create mode 100644 internal/bridge/credentials/store_test.go create mode 100644 internal/bridge/credits.go create mode 100644 internal/bridge/mock_listener.go create mode 100644 internal/bridge/mocks/mocks.go create mode 100644 internal/bridge/release_notes.go create mode 100644 internal/bridge/types.go create mode 100644 internal/bridge/user.go create mode 100644 internal/bridge/user_credentials_test.go create mode 100644 internal/bridge/user_new_test.go create mode 100644 internal/bridge/user_test.go create mode 100644 internal/bridge/useragent.go create mode 100644 internal/bridge/useragent_test.go create mode 100644 internal/events/events.go create mode 100644 internal/frontend/autoconfig/applemail.go create mode 100644 internal/frontend/autoconfig/autoconfig.go create mode 100644 internal/frontend/cli/account_utils.go create mode 100644 internal/frontend/cli/accounts.go create mode 100644 internal/frontend/cli/frontend.go create mode 100644 internal/frontend/cli/system.go create mode 100644 internal/frontend/cli/updates.go create mode 100644 internal/frontend/cli/utils.go create mode 100644 internal/frontend/frontend.go create mode 100644 internal/frontend/qml/BridgeUI/AccountDelegate.qml create mode 100644 internal/frontend/qml/BridgeUI/BubbleMenu.qml create mode 100644 internal/frontend/qml/BridgeUI/Credits.qml create mode 100644 internal/frontend/qml/BridgeUI/DialogFirstStart.qml create mode 100644 internal/frontend/qml/BridgeUI/DialogPortChange.qml create mode 100644 internal/frontend/qml/BridgeUI/DialogTLSCertInfo.qml create mode 100644 internal/frontend/qml/BridgeUI/DialogYesNo.qml create mode 100644 internal/frontend/qml/BridgeUI/HelpView.qml create mode 100644 internal/frontend/qml/BridgeUI/InfoWindow.qml create mode 100644 internal/frontend/qml/BridgeUI/MainWindow.qml create mode 100644 internal/frontend/qml/BridgeUI/ManualWindow.qml create mode 100644 internal/frontend/qml/BridgeUI/OutgoingNoEncPopup.qml create mode 100644 internal/frontend/qml/BridgeUI/SettingsView.qml create mode 100644 internal/frontend/qml/BridgeUI/StatusFooter.qml create mode 100644 internal/frontend/qml/BridgeUI/VersionInfo.qml create mode 100644 internal/frontend/qml/BridgeUI/qmldir create mode 100644 internal/frontend/qml/Gui.qml create mode 100644 internal/frontend/qml/ProtonUI/AccessibleButton.qml create mode 100644 internal/frontend/qml/ProtonUI/AccessibleSelectableText.qml create mode 100644 internal/frontend/qml/ProtonUI/AccessibleText.qml create mode 100644 internal/frontend/qml/ProtonUI/AccountView.qml create mode 100644 internal/frontend/qml/ProtonUI/AddAccountBar.qml create mode 100644 internal/frontend/qml/ProtonUI/BubbleNote.qml create mode 100644 internal/frontend/qml/ProtonUI/BugReportWindow.qml create mode 100644 internal/frontend/qml/ProtonUI/ButtonIconText.qml create mode 100644 internal/frontend/qml/ProtonUI/ButtonRounded.qml create mode 100644 internal/frontend/qml/ProtonUI/CheckBoxLabel.qml create mode 100644 internal/frontend/qml/ProtonUI/ClickIconText.qml create mode 100644 internal/frontend/qml/ProtonUI/Dialog.qml create mode 100644 internal/frontend/qml/ProtonUI/DialogAddUser.qml create mode 100644 internal/frontend/qml/ProtonUI/DialogConnectionTroubleshoot.qml create mode 100644 internal/frontend/qml/ProtonUI/DialogUpdate.qml create mode 100644 internal/frontend/qml/ProtonUI/FileAndFolderSelect.qml create mode 100644 internal/frontend/qml/ProtonUI/InfoToolTip.qml create mode 100644 internal/frontend/qml/ProtonUI/InformationBar.qml create mode 100644 internal/frontend/qml/ProtonUI/InputBox.qml create mode 100644 internal/frontend/qml/ProtonUI/InputField.qml create mode 100644 internal/frontend/qml/ProtonUI/InstanceExistsWindow.qml create mode 100644 internal/frontend/qml/ProtonUI/LogoHeader.qml create mode 100644 internal/frontend/qml/ProtonUI/PopupMessage.qml create mode 100644 internal/frontend/qml/ProtonUI/ProgressBar.qml create mode 100644 internal/frontend/qml/ProtonUI/Style.qml create mode 100644 internal/frontend/qml/ProtonUI/TLSCertPinIssueBar.qml create mode 100644 internal/frontend/qml/ProtonUI/TabButton.qml create mode 100644 internal/frontend/qml/ProtonUI/TabLabels.qml create mode 100644 internal/frontend/qml/ProtonUI/TextLabel.qml create mode 100644 internal/frontend/qml/ProtonUI/TextValue.qml create mode 100644 internal/frontend/qml/ProtonUI/WindowTitleBar.qml create mode 100644 internal/frontend/qml/ProtonUI/qmldir create mode 100644 internal/frontend/qml/tst_Gui.qml create mode 100644 internal/frontend/qt/Makefile.local create mode 100644 internal/frontend/qt/accountModel.go create mode 100644 internal/frontend/qt/accounts.go create mode 100644 internal/frontend/qt/frontend.go create mode 100644 internal/frontend/qt/frontend_nogui.go create mode 100644 internal/frontend/qt/helpers.go create mode 100644 internal/frontend/qt/logs.cpp create mode 100644 internal/frontend/qt/logs.go create mode 100644 internal/frontend/qt/logs.h create mode 100644 internal/frontend/qt/notification.go create mode 100644 internal/frontend/qt/resources.qrc create mode 100644 internal/frontend/qt/systray.go create mode 100644 internal/frontend/qt/translate.ts create mode 100644 internal/frontend/qt/ui.go create mode 100644 internal/frontend/share/fontawesome-webfont.ttf create mode 100644 internal/frontend/share/icon.rc create mode 100644 internal/frontend/share/icons/Bridge.icns create mode 100644 internal/frontend/share/icons/all_icons.svg create mode 100644 internal/frontend/share/icons/black-syserror.png create mode 100644 internal/frontend/share/icons/black-systray.png create mode 100644 internal/frontend/share/icons/black-syswarn.png create mode 100755 internal/frontend/share/icons/export.sh create mode 100644 internal/frontend/share/icons/logo.ico create mode 100644 internal/frontend/share/icons/logo.svg create mode 100644 internal/frontend/share/icons/macos_gray.png create mode 100644 internal/frontend/share/icons/macos_green.png create mode 100644 internal/frontend/share/icons/macos_green_dark.png create mode 100644 internal/frontend/share/icons/macos_green_hl.png create mode 100644 internal/frontend/share/icons/macos_red.png create mode 100644 internal/frontend/share/icons/macos_red_dark.png create mode 100644 internal/frontend/share/icons/macos_red_hl.png create mode 100644 internal/frontend/share/icons/macos_yellow.png create mode 100644 internal/frontend/share/icons/macos_yellow_dark.png create mode 100644 internal/frontend/share/icons/macos_yellow_hl.png create mode 100644 internal/frontend/share/icons/pm_logo.png create mode 100644 internal/frontend/share/icons/rectangle-app.png create mode 100644 internal/frontend/share/icons/rectangle-systray.png create mode 100644 internal/frontend/share/icons/rectangle-syswarn.png create mode 100644 internal/frontend/share/icons/rounded-app.png create mode 100644 internal/frontend/share/icons/rounded-app.svg create mode 100644 internal/frontend/share/icons/rounded-systray.png create mode 100644 internal/frontend/share/icons/rounded-syswarn.png create mode 100644 internal/frontend/share/icons/white-syserror.png create mode 100644 internal/frontend/share/icons/white-systray.png create mode 100644 internal/frontend/share/icons/white-syswarn.png create mode 100644 internal/frontend/share/icons/win10_Dash.png create mode 100644 internal/frontend/share/icons/win10_Times.png create mode 100644 internal/frontend/types/types.go create mode 100644 internal/imap/backend.go create mode 100644 internal/imap/backend_cache.go create mode 100644 internal/imap/bridge.go create mode 100644 internal/imap/cache/cache.go create mode 100644 internal/imap/cache/cache_test.go create mode 100644 internal/imap/imap.go create mode 100644 internal/imap/mailbox.go create mode 100644 internal/imap/mailbox_message.go create mode 100644 internal/imap/mailbox_message_test.go create mode 100644 internal/imap/mailbox_messages.go create mode 100644 internal/imap/mailbox_root.go create mode 100644 internal/imap/server.go create mode 100644 internal/imap/store.go create mode 100644 internal/imap/uidplus/extension.go create mode 100644 internal/imap/uidplus/extension_test.go create mode 100644 internal/imap/user.go create mode 100644 internal/imap/utils.go create mode 100644 internal/metrics/metrics.go create mode 100644 internal/pmapifactory/pmapi_noprod.go create mode 100644 internal/pmapifactory/pmapi_prod.go create mode 100644 internal/preferences/preferences.go create mode 100644 internal/smtp/backend.go create mode 100644 internal/smtp/bridge.go create mode 100644 internal/smtp/send_recorder.go create mode 100644 internal/smtp/send_recorder_test.go create mode 100644 internal/smtp/sending_info.go create mode 100644 internal/smtp/sending_info_test.go create mode 100644 internal/smtp/server.go create mode 100644 internal/smtp/smtp.go create mode 100644 internal/smtp/store.go create mode 100644 internal/smtp/user.go create mode 100644 internal/smtp/utils.go create mode 100644 internal/smtp/vcard_tools.go create mode 100644 internal/store/address.go create mode 100644 internal/store/address_mailbox.go create mode 100644 internal/store/address_message.go create mode 100644 internal/store/cache.go create mode 100644 internal/store/change.go create mode 100644 internal/store/change_test.go create mode 100644 internal/store/convert.go create mode 100644 internal/store/event_loop.go create mode 100644 internal/store/event_loop_test.go create mode 100644 internal/store/mailbox.go create mode 100644 internal/store/mailbox_counts.go create mode 100644 internal/store/mailbox_counts_test.go create mode 100644 internal/store/mailbox_ids.go create mode 100644 internal/store/mailbox_ids_test.go create mode 100644 internal/store/mailbox_message.go create mode 100644 internal/store/main_test.go create mode 100644 internal/store/message.go create mode 100644 internal/store/mocks/mocks.go create mode 100644 internal/store/mocks/utils_mocks.go create mode 100644 internal/store/store.go create mode 100644 internal/store/store_address_mode.go create mode 100644 internal/store/store_structure_version.go create mode 100644 internal/store/store_test.go create mode 100644 internal/store/store_test_exports.go create mode 100644 internal/store/sync.go create mode 100644 internal/store/sync_state.go create mode 100644 internal/store/sync_state_test.go create mode 100644 internal/store/sync_test.go create mode 100644 internal/store/types.go create mode 100644 internal/store/ulimit.go create mode 100644 internal/store/user.go create mode 100644 internal/store/user_address.go create mode 100644 internal/store/user_address_info.go create mode 100644 internal/store/user_mailbox.go create mode 100644 internal/store/user_message.go create mode 100644 internal/store/user_message_test.go create mode 100644 internal/store/user_sync.go create mode 100644 internal/store/user_sync_test.go create mode 100644 pkg/algo/algo.go create mode 100644 pkg/algo/sets.go create mode 100644 pkg/algo/sets_test.go create mode 100644 pkg/args/args.go create mode 100644 pkg/config/config.go create mode 100644 pkg/config/config_test.go create mode 100644 pkg/config/logs.go create mode 100644 pkg/config/logs_all.go create mode 100644 pkg/config/logs_qa.go create mode 100644 pkg/config/logs_test.go create mode 100644 pkg/config/mock_config.go create mode 100644 pkg/config/preferences.go create mode 100644 pkg/config/preferences_test.go create mode 100644 pkg/config/tls.go create mode 100644 pkg/config/tls_test.go create mode 100644 pkg/connection/check_connection.go create mode 100644 pkg/connection/check_connection_test.go create mode 100644 pkg/dialer/dial_client.go create mode 100644 pkg/keychain/keychain.go create mode 100644 pkg/keychain/keychain_darwin.go create mode 100644 pkg/keychain/keychain_linux.go create mode 100644 pkg/keychain/keychain_test.go create mode 100644 pkg/keychain/keychain_windows.go create mode 100644 pkg/listener/listener.go create mode 100644 pkg/listener/listener_test.go create mode 100644 pkg/message/address.go create mode 100644 pkg/message/body.go create mode 100644 pkg/message/envelope.go create mode 100644 pkg/message/flags.go create mode 100644 pkg/message/header.go create mode 100644 pkg/message/html.go create mode 100644 pkg/message/message.go create mode 100644 pkg/message/parser.go create mode 100644 pkg/message/parser_test.go create mode 100644 pkg/message/section.go create mode 100644 pkg/message/section_test.go create mode 100644 pkg/mime/Changelog.md create mode 100644 pkg/mime/encoding.go create mode 100644 pkg/mime/encoding_test.go create mode 100644 pkg/mime/mediaType.go create mode 100644 pkg/mime/parser.go create mode 100644 pkg/mime/parser_test.go create mode 100644 pkg/mime/utf7Decoder.go create mode 100644 pkg/parallel/parallel.go create mode 100644 pkg/parallel/parallel_test.go create mode 100644 pkg/pmapi/Changelog.md create mode 100644 pkg/pmapi/Makefile create mode 100644 pkg/pmapi/addresses.go create mode 100644 pkg/pmapi/addresses_test.go create mode 100644 pkg/pmapi/attachments.go create mode 100644 pkg/pmapi/attachments_test.go create mode 100644 pkg/pmapi/auth.go create mode 100644 pkg/pmapi/auth_test.go create mode 100644 pkg/pmapi/auth_test_export.go create mode 100644 pkg/pmapi/bugs.go create mode 100644 pkg/pmapi/bugs_test.go create mode 100644 pkg/pmapi/client.go create mode 100644 pkg/pmapi/client_test.go create mode 100644 pkg/pmapi/config.go create mode 100644 pkg/pmapi/config_dev.go create mode 100644 pkg/pmapi/config_local.go create mode 100644 pkg/pmapi/config_nopin.go create mode 100644 pkg/pmapi/conrep.go create mode 100644 pkg/pmapi/contacts.go create mode 100644 pkg/pmapi/contacts_test.go create mode 100644 pkg/pmapi/conversations.go create mode 100644 pkg/pmapi/dialer_with_proxy.go create mode 100644 pkg/pmapi/dialer_with_proxy_test.go create mode 100644 pkg/pmapi/events.go create mode 100644 pkg/pmapi/events_test.go create mode 100644 pkg/pmapi/import.go create mode 100644 pkg/pmapi/import_test.go create mode 100644 pkg/pmapi/key.go create mode 100644 pkg/pmapi/keyring.go create mode 100644 pkg/pmapi/keyring_test.go create mode 100644 pkg/pmapi/labels.go create mode 100644 pkg/pmapi/labels_test.go create mode 100644 pkg/pmapi/messages.go create mode 100644 pkg/pmapi/messages_test.go create mode 100644 pkg/pmapi/metrics.go create mode 100644 pkg/pmapi/metrics_test.go create mode 100644 pkg/pmapi/passwords.go create mode 100644 pkg/pmapi/pmapi_test.go create mode 100644 pkg/pmapi/proxy.go create mode 100644 pkg/pmapi/proxy_test.go create mode 100644 pkg/pmapi/req.go create mode 100644 pkg/pmapi/res.go create mode 100644 pkg/pmapi/sentry.go create mode 100644 pkg/pmapi/sentry_test.go create mode 100644 pkg/pmapi/server_test.go create mode 100644 pkg/pmapi/settings.go create mode 100644 pkg/pmapi/testdata/keyring_addressKeysPrimaryHasToken_JSON create mode 100644 pkg/pmapi/testdata/keyring_addressKeysSecondaryHasToken_JSON create mode 100644 pkg/pmapi/testdata/keyring_addressKeysWithTokens_JSON create mode 100644 pkg/pmapi/testdata/keyring_addressKeysWithoutTokens_JSON create mode 100644 pkg/pmapi/testdata/keyring_userKey create mode 100644 pkg/pmapi/testdata/keyring_userKey_JSON create mode 100644 pkg/pmapi/testdata/routes/HTTP_200.json create mode 100644 pkg/pmapi/testdata/routes/HTTP_401.json create mode 100644 pkg/pmapi/testdata/routes/HTTP_402.json create mode 100644 pkg/pmapi/testdata/routes/addresses/get_response.json create mode 100644 pkg/pmapi/testdata/routes/auth/2fa/post_401_bad_password.json create mode 100644 pkg/pmapi/testdata/routes/auth/2fa/post_422_bad_password.json create mode 100644 pkg/pmapi/testdata/routes/auth/2fa/post_response.json create mode 100644 pkg/pmapi/testdata/routes/auth/delete_response.json create mode 100644 pkg/pmapi/testdata/routes/auth/enc_priv_key.asc create mode 100644 pkg/pmapi/testdata/routes/auth/encrypted_access_token.asc create mode 100644 pkg/pmapi/testdata/routes/auth/info/post_response.json create mode 100644 pkg/pmapi/testdata/routes/auth/post_response.json create mode 100644 pkg/pmapi/testdata/routes/auth/refresh/post_resp_has_uid.json create mode 100644 pkg/pmapi/testdata/routes/auth/refresh/post_response.json create mode 100644 pkg/pmapi/testdata/routes/contacts/put_response.json create mode 100644 pkg/pmapi/testdata/routes/keys/salts/get_response.json create mode 100644 pkg/pmapi/testdata/routes/messages/get_response.json create mode 100644 pkg/pmapi/testdata/routes/messages/label/put_response.json create mode 100644 pkg/pmapi/testdata/routes/users/get_response.json create mode 100644 pkg/pmapi/testdata/symmetric_key.json create mode 100644 pkg/pmapi/testdata/testPrivateKey create mode 100644 pkg/pmapi/testdata/testPrivateKeyLegacy create mode 100644 pkg/pmapi/testdata/testPublicKey create mode 100644 pkg/pmapi/users.go create mode 100644 pkg/pmapi/users_test.go create mode 100644 pkg/ports/ports.go create mode 100644 pkg/ports/ports_test.go create mode 100644 pkg/srp/hash.go create mode 100644 pkg/srp/srp.go create mode 100644 pkg/srp/srp_test.go create mode 100644 pkg/updates/bridge_pubkey.gpg create mode 100644 pkg/updates/compare_versions.go create mode 100644 pkg/updates/compare_versions_test.go create mode 100644 pkg/updates/downloader.go create mode 100644 pkg/updates/progress.go create mode 100644 pkg/updates/signature.go create mode 100644 pkg/updates/sync.go create mode 100644 pkg/updates/sync_test.go create mode 100644 pkg/updates/tar.go create mode 100644 pkg/updates/testdata/current_version_linux.json create mode 100644 pkg/updates/testdata/current_version_linux.json.sig create mode 100644 pkg/updates/updates.go create mode 100644 pkg/updates/updates_beta.go create mode 100644 pkg/updates/updates_qa.go create mode 100644 pkg/updates/updates_test.go create mode 100644 pkg/updates/version_info.go create mode 100644 pkg/useragent/useragent.go create mode 100644 pkg/useragent/useragent_test.go create mode 100644 release-notes/bugs.txt create mode 100644 release-notes/notes.txt create mode 100644 test/Makefile create mode 100644 test/README.md create mode 100644 test/accounts/account.go create mode 100644 test/accounts/accounts.go create mode 100644 test/accounts/fake.json create mode 100644 test/api_checks_test.go create mode 100644 test/bdd_test.go create mode 100644 test/benchmarks/bench_results/human-table.py create mode 100644 test/benchmarks/bench_test.go create mode 100644 test/bridge_actions_test.go create mode 100644 test/bridge_checks_test.go create mode 100644 test/bridge_setup_test.go create mode 100644 test/context/accounts.go create mode 100644 test/context/bddt.go create mode 100644 test/context/bridge.go create mode 100644 test/context/bridge_panic_handler.go create mode 100644 test/context/bridge_user.go create mode 100644 test/context/cleaner.go create mode 100644 test/context/config.go create mode 100644 test/context/context.go create mode 100644 test/context/credentials.go create mode 100644 test/context/environments.go create mode 100644 test/context/imap.go create mode 100644 test/context/pmapi_controller.go create mode 100644 test/context/smtp.go create mode 100644 test/context/utils.go create mode 100644 test/fakeapi/attachments.go create mode 100644 test/fakeapi/auth.go create mode 100644 test/fakeapi/contacts.go create mode 100644 test/fakeapi/controller.go create mode 100644 test/fakeapi/controller_calls.go create mode 100644 test/fakeapi/controller_control.go create mode 100644 test/fakeapi/controller_session.go create mode 100644 test/fakeapi/controller_user.go create mode 100644 test/fakeapi/counts.go create mode 100644 test/fakeapi/events.go create mode 100644 test/fakeapi/fakeapi.go create mode 100644 test/fakeapi/idgenerator.go create mode 100644 test/fakeapi/keys.go create mode 100644 test/fakeapi/labels.go create mode 100644 test/fakeapi/messages.go create mode 100644 test/fakeapi/reports.go create mode 100644 test/fakeapi/user.go create mode 100644 test/fakeapi/utils.go create mode 100644 test/features/bridge/addressmode.feature create mode 100644 test/features/bridge/deleteuser.feature create mode 100644 test/features/bridge/login.feature create mode 100644 test/features/bridge/relogin.feature create mode 100644 test/features/bridge/start.feature create mode 100644 test/features/bridge/sync.feature create mode 100644 test/features/imap/auth.feature create mode 100644 test/features/imap/idle/basic.feature create mode 100644 test/features/imap/idle/two_users.feature create mode 100644 test/features/imap/mailbox/create.feature create mode 100644 test/features/imap/mailbox/delete.feature create mode 100644 test/features/imap/mailbox/info.feature create mode 100644 test/features/imap/mailbox/list.feature create mode 100644 test/features/imap/mailbox/rename.feature create mode 100644 test/features/imap/mailbox/select.feature create mode 100644 test/features/imap/mailbox/status.feature create mode 100644 test/features/imap/message/copy.feature create mode 100644 test/features/imap/message/create.feature create mode 100644 test/features/imap/message/delete.feature create mode 100644 test/features/imap/message/fetch.feature create mode 100644 test/features/imap/message/move.feature create mode 100644 test/features/imap/message/search.feature create mode 100644 test/features/imap/message/update.feature create mode 100644 test/features/smtp/auth.feature create mode 100644 test/features/smtp/send/bcc.feature create mode 100644 test/features/smtp/send/failures.feature create mode 100644 test/features/smtp/send/html.feature create mode 100644 test/features/smtp/send/html_att.feature create mode 100644 test/features/smtp/send/plain.feature create mode 100644 test/features/smtp/send/plain_att.feature create mode 100644 test/features/smtp/send/same_message.feature create mode 100644 test/features/smtp/send/two_messages.feature create mode 100644 test/imap_actions_auth_test.go create mode 100644 test/imap_actions_mailbox_test.go create mode 100644 test/imap_actions_messages_test.go create mode 100644 test/imap_checks_test.go create mode 100644 test/imap_setup_test.go create mode 100644 test/internal_error.go create mode 100644 test/liveapi/calls.go create mode 100644 test/liveapi/cleanup.go create mode 100644 test/liveapi/controller.go create mode 100644 test/liveapi/labels.go create mode 100644 test/liveapi/messages.go create mode 100644 test/liveapi/transport.go create mode 100644 test/liveapi/users.go create mode 100644 test/main_test.go create mode 100644 test/mocks/debug.go create mode 100644 test/mocks/imap.go create mode 100644 test/mocks/imap_response.go create mode 100644 test/mocks/smtp.go create mode 100644 test/mocks/smtp_response.go create mode 100644 test/mocks/testingt.go create mode 100644 test/mocks/utils.go create mode 100644 test/smtp_actions_test.go create mode 100644 test/smtp_checks_test.go create mode 100644 test/smtp_setup_test.go create mode 100644 test/store_actions_test.go create mode 100644 test/store_checks_test.go create mode 100644 test/store_setup_test.go create mode 100644 test/testdata/address_key.json create mode 100644 test/testdata/user_key.json create mode 100644 utils/addcert.scpt create mode 100755 utils/credits.sh create mode 100644 utils/dxtn/dxtn.cbp create mode 100644 utils/dxtn/dxtn.layout create mode 100644 utils/dxtn/main.cpp create mode 100644 utils/dxtn/main.h create mode 100755 utils/release-notes.sh create mode 100644 utils/sync_bench.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..d6c5853d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +Changelog.md merge=union diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..dde5efc4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# System files +*.app +*.DS_Store + +# Editor files +.*.sw? +*~ + +# Compiled Object files, Static and Dynamic libs (Shared Objects) +vendor + +# Test files +godog.test +debug.test +coverage.html + +# Run files +mem.pprof + +# Auto generated frontend +frontend/qml/BridgeUI/*.qmlc +frontend/qml/ProtonUI/*.qmlc +frontend/qml/ProtonUI/fontawesome.ttf +frontend/qml/ProtonUI/images +frontend/qml/*.qmlc diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 00000000..b5095a62 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,109 @@ +image: gitlab.protontech.ch:4567/protonmail/desktop-bridge/ci + +before_script: + - eval $(ssh-agent -s) + - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null + + - mkdir -p .cache/bin + - export PATH=$(pwd)/.cache/bin:$PATH + - export GOPATH="$CI_PROJECT_DIR/.cache" + + - make install-dev-dependencies + +cache: + key: go-mod + paths: + - .cache + policy: pull + +stages: + - image + - cache + - test + - build + +# Stage: IMAGE + +build-ci-image: + stage: image + image: docker:stable + before_script: [] + cache: {} + tags: + - heavy + only: + changes: + - ci/* + services: + - docker:dind + script: + - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY + - docker info + - docker build -t gitlab.protontech.ch:4567/protonmail/desktop-bridge/ci:latest ci + - docker push gitlab.protontech.ch:4567/protonmail/desktop-bridge/ci:latest + +# Stage: CACHE + +# This will ensure latest dependency versions and updates the cache for +# all other following jobs which only pull the cache. +cache-push: + stage: cache + only: + - branches + script: + - echo "" + cache: + key: go-mod + paths: + - .cache + +# Stage: TEST + +lint: + stage: test + only: + - branches + script: + - make lint + +test: + stage: test + only: + - branches + script: + - apt-get -y install pass gnupg rng-tools + # First have enough of entropy (cat /proc/sys/kernel/random/entropy_avail). + - rngd -r /dev/urandom + # Generate GPG key without password for the password manager. + - gpg --batch --yes --passphrase '' --quick-generate-key 'tester@example.com' + # Use the last created GPG ID for the password manager. + - pass init `gpg --list-keys | grep "^ " | tail -1 | tr -d '[:space:]'` + # Then finally run the tests + - make test + +test-integration: + stage: test + only: + - branches + script: + - VERBOSITY=debug make -C test test + +dependency-updates: + stage: test + script: + - make updates + +# Stage: BUILD + +build-linux: + stage: build + # Test build every time (= we want to know build is possible). + only: + - branches + script: + - make build + artifacts: + name: "bridge-linux-$CI_COMMIT_REF_NAME-$CI_COMMIT_SHORT_SHA" + paths: + - bridge_*.tgz + expire_in: 2 week diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..e69de29b diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..4dd92c9e --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,66 @@ +run: + timeout: 10m + build-tags: + - nogui + skip-dirs: + - pkg/mime + +issues: + exclude-use-default: false + exclude: + - Using the variable on range scope `tt` in function literal + - should have comment (\([^)]+\) )?or be unexported # For now we are missing a lot of comments. + - at least one file in a package should have a package comment # For now we are missing a lot of comments. + + exclude-rules: + - path: _test\.go + linters: + - dupl + - funlen + - gochecknoglobals + - gochecknoinits + - gosec + +linters: + # setting disable-all will make only explicitly enabled linters run + disable-all: true + + enable: + - deadcode # Finds unused code [fast: true, auto-fix: false] + - errcheck # Errcheck is a program for checking for unchecked errors in go programs. These unchecked errors can be critical bugs in some cases [fast: true, auto-fix: false] + - gosimple # Linter for Go source code that specializes in simplifying a code [fast: true, auto-fix: false] + - govet # Vet examines Go source code and reports suspicious constructs, such as Printf calls whose arguments do not align with the format string [fast: true, auto-fix: false] + - ineffassign # Detects when assignments to existing variables are not used [fast: true, auto-fix: false] + - staticcheck # Staticcheck is a go vet on steroids, applying a ton of static analysis checks [fast: true, auto-fix: false] + - structcheck # Finds unused struct fields [fast: true, auto-fix: false] + - typecheck # Like the front-end of a Go compiler, parses and type-checks Go code [fast: true, auto-fix: false] + - unused # Checks Go code for unused constants, variables, functions and types [fast: false, auto-fix: false] + - varcheck # Finds unused global variables and constants [fast: true, auto-fix: false] + - bodyclose # checks whether HTTP response body is closed successfully [fast: true, auto-fix: false] + - depguard # Go linter that checks if package imports are in a list of acceptable packages [fast: true, auto-fix: false] + - dogsled # Checks assignments with too many blank identifiers (e.g. x, _, _, _, := f()) [fast: true, auto-fix: false] + - dupl # Tool for code clone detection [fast: true, auto-fix: false] + - funlen # Tool for detection of long functions [fast: true, auto-fix: false] + - gochecknoglobals # Checks that no globals are present in Go code [fast: true, auto-fix: false] + - gochecknoinits # Checks that no init functions are present in Go code [fast: true, auto-fix: false] + #- gocognit # Computes and checks the cognitive complexity of functions [fast: true, auto-fix: false] + - goconst # Finds repeated strings that could be replaced by a constant [fast: true, auto-fix: false] + - gocritic # The most opinionated Go source code linter [fast: true, auto-fix: false] + - gocyclo # Computes and checks the cyclomatic complexity of functions [fast: true, auto-fix: false] + - godox # Tool for detection of FIXME, TODO and other comment keywords [fast: true, auto-fix: false] + - gofmt # Gofmt checks whether code was gofmt-ed. By default this tool runs with -s option to check for code simplification [fast: true, auto-fix: true] + - goimports # Goimports does everything that gofmt does. Additionally it checks unused imports [fast: true, auto-fix: true] + - golint # Golint differs from gofmt. Gofmt reformats Go source code, whereas golint prints out style mistakes [fast: true, auto-fix: false] + - gosec # Inspects source code for security problems [fast: true, auto-fix: false] + - interfacer # Linter that suggests narrower interface types [fast: true, auto-fix: false] + - maligned # Tool to detect Go structs that would take less memory if their fields were sorted [fast: true, auto-fix: false] + - misspell # Finds commonly misspelled English words in comments [fast: true, auto-fix: true] + - nakedret # Finds naked returns in functions greater than a specified function length [fast: true, auto-fix: false] + - prealloc # Finds slice declarations that could potentially be preallocated [fast: true, auto-fix: false] + - scopelint # Scopelint checks for unpinned variables in go programs [fast: true, auto-fix: false] + - stylecheck # Stylecheck is a replacement for golint [fast: true, auto-fix: false] + - unconvert # Remove unnecessary type conversions [fast: true, auto-fix: false] + - unparam # Reports unused function parameters [fast: true, auto-fix: false] + - whitespace # Tool for detection of leading and trailing whitespace [fast: true, auto-fix: true] + #- wsl # Whitespace Linter - Forces you to use empty lines! [fast: true, auto-fix: false] + #- lll # Reports long lines [fast: true, auto-fix: false] diff --git a/BUILDS.md b/BUILDS.md new file mode 100644 index 00000000..f48142aa --- /dev/null +++ b/BUILDS.md @@ -0,0 +1,38 @@ +# Building ProtonMail Bridge app + +## Prerequisites +* Go 1.13 +* Bash with basic build utils: make, gcc, sed, find, grep, … +* For Windows it is recommended to use MinGW 64bit shell from [MSYS2](https://www.msys2.org/) +* GCC (linux, windows) or Xcode (macOS) +* Windres (windows) + +To enable the sending of crash reports using Sentry please set the +`main.DSNSentry` value with client key of your sentry project before build. +Otherwise sending of crash reports will be disabled. + +## Build +* for Windows please unset the `MSYSTEM` variable + +```bash +export MSYSTEM= +``` + +* in project root run + +```bash +make build +``` + +* The result will be stored in `./cmd/Destop-Bridge/deploy/${GOOS}/` + * for `linux` binary will the name of project directory e.g `bridge` + * for `windows` the binary has extension `.exe` e.g `bridge.exe` + * for `darwin` the application will be created with name of project directory e.g `bridge.app` + +## Usefull tests, lints and checks +In order to be able to run following commands please install development dependencies: `make install-dev-dependencies` + +* `make test` will run unit test for whole project +* `make lint` will run liter for whole project +* `make -C ./tests test` will run integration tests for Bridge application +* `make run` will compile without GUI and start Bridge application in CLI mode diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..824a8c84 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,11 @@ +# Contribution Policy + +By making a contribution to this project: + +1. I assign any and all copyright related to the contribution to + Proton Technologies AG; +2. I certify that the contribution was created in whole by me; +3. I understand and agree that this project and the contribution are public + and that a record of the contribution (including all personal information I + submit with it) is maintained indefinitely and may be redistributed with + this project or the open source license(s) involved. diff --git a/COPYING.md b/COPYING.md new file mode 100644 index 00000000..0de9800b --- /dev/null +++ b/COPYING.md @@ -0,0 +1,73 @@ +# Copying +Copyright (c) 2020 Proton Technologies AG + +ProtonMail 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. + +ProtonMail 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 +ProtonMail Bridge. If not, see https://www.gnu.org/licenses. + + +# Dependencies +ProtonMail Bridge app includes the following libraries from Proton Technologies AG: + +* [GopenPGP library](https://gopenpgp.org/) | The [MIT License](https://github.com/ProtonMail/gopenpgp/blob/master/LICENSE). + +ProtonMail Bridge includes the following 3rd party software: + +* [The Go Project libraries](https://golang.org/project/) | Available under [BSD license](https://golang.org/LICENSE) +* [Qt Go binding](https://github.com/therecipe/qt) | Available under [LGPLv3 license](https://github.com/therecipe/qt/blob/master/LICENSE) +* [Qt](https://www.qt.io/) | Available under [multiple licences](https://www.qt.io/licensing) +* [Font Awesome 4.7.0](https://fontawesome.com/v4.7.0/) | Available under [multiple licenses](https://fontawesome.com/v4.7.0/license/) + +* [notificator](github.com/0xAX/notificator) | Available under [license](https://github.com/0xAX/notificator/blob/master/LICENSE) +* [ishell](github.com/abiosoft/ishell) | Available under [license](https://github.com/abiosoft/ishell/blob/master/LICENSE) +* [readline](github.com/abiosoft/readline) | Available under [license](https://github.com/abiosoft/readline/blob/master/LICENSE) +* [singleinstance](github.com/allan-simon/go-singleinstance) | Available under [license](https://github.com/allan-simon/go-singleinstance/blob/master/LICENSE) +* [cascadia](github.com/andybalholm/cascadia) | Available under [license](https://github.com/andybalholm/cascadia/blob/master/LICENSE) +* [gocertifi](github.com/certifi/gocertifi) | Available under [license](https://github.com/certifi/gocertifi/blob/master/LICENSE) +* [logex](github.com/chzyer/logex) | Available under [license](https://github.com/chzyer/logex/blob/master/LICENSE) +* [test](github.com/chzyer/test) | Available under [license](https://github.com/chzyer/test/blob/master/LICENSE) +* [godog](github.com/cucumber/godog) | Available under [license](https://github.com/cucumber/godog/blob/master/LICENSE) +* [wincred](github.com/danieljoos/wincred) | Available under [license](https://github.com/danieljoos/wincred/blob/master/LICENSE) +* [credential-helpers](github.com/docker/docker-credential-helpers) | Available under [license](https://github.com/docker/docker-credential-helpers/blob/master/LICENSE) +* [imap](github.com/emersion/go-imap) | Available under [license](https://github.com/emersion/go-imap/blob/master/LICENSE) +* [imap-appendlimit](github.com/emersion/go-imap-appendlimit) | Available under [license](https://github.com/emersion/go-imap-appendlimit/blob/master/LICENSE) +* [imap-idle](github.com/emersion/go-imap-idle) | Available under [license](https://github.com/emersion/go-imap-idle/blob/master/LICENSE) +* [imap-quota](github.com/emersion/go-imap-quota) | Available under [license](https://github.com/emersion/go-imap-quota/blob/master/LICENSE) +* [imap-specialuse](github.com/emersion/go-imap-specialuse) | Available under [license](https://github.com/emersion/go-imap-specialuse/blob/master/LICENSE) +* [sasl](github.com/emersion/go-sasl) | Available under [license](https://github.com/emersion/go-sasl/blob/master/LICENSE) +* [smtp](github.com/emersion/go-smtp) | Available under [license](https://github.com/emersion/go-smtp/blob/master/LICENSE) +* [textwrapper](github.com/emersion/go-textwrapper) | Available under [license](https://github.com/emersion/go-textwrapper/blob/master/LICENSE) +* [vcard](github.com/emersion/go-vcard) | Available under [license](https://github.com/emersion/go-vcard/blob/master/LICENSE) +* [color](github.com/fatih/color) | Available under [license](https://github.com/fatih/color/blob/master/LICENSE) +* [shlex](github.com/flynn-archive/go-shlex) | Available under [license](https://github.com/flynn-archive/go-shlex/blob/master/LICENSE) +* [raven](github.com/getsentry/raven-go) | Available under [license](https://github.com/getsentry/raven-go/blob/master/LICENSE) +* [resty](github.com/go-resty/resty/v2) | Available under [license](https://github.com/go-resty/resty/v2/blob/master/LICENSE) +* [mock](github.com/golang/mock) | Available under [license](https://github.com/golang/mock/blob/master/LICENSE) +* [cmp](github.com/google/go-cmp) | Available under [license](https://github.com/google/go-cmp/blob/master/LICENSE) +* [gopherjs](github.com/gopherjs/gopherjs) | Available under [license](https://github.com/gopherjs/gopherjs/blob/master/LICENSE) +* [multierror](github.com/hashicorp/go-multierror) | Available under [license](https://github.com/hashicorp/go-multierror/blob/master/LICENSE) +* [bcrypt](github.com/jameskeane/bcrypt) | Available under [license](https://github.com/jameskeane/bcrypt/blob/master/LICENSE) +* [html2text](github.com/jaytaylor/html2text) | Available under [license](https://github.com/jaytaylor/html2text/blob/master/LICENSE) +* [enmime](github.com/jhillyerd/enmime) | Available under [license](https://github.com/jhillyerd/enmime/blob/master/LICENSE) +* [osext](github.com/kardianos/osext) | Available under [license](https://github.com/kardianos/osext/blob/master/LICENSE) +* [keychain](github.com/keybase/go-keychain) | Available under [license](https://github.com/keybase/go-keychain/blob/master/LICENSE) +* [aurora](github.com/logrusorgru/aurora) | Available under [license](https://github.com/logrusorgru/aurora/blob/master/LICENSE) +* [dns](github.com/miekg/dns) | Available under [license](https://github.com/miekg/dns/blob/master/LICENSE) +* [uuid](github.com/myesui/uuid) | Available under [license](https://github.com/myesui/uuid/blob/master/LICENSE) +* [jsondiff](github.com/nsf/jsondiff) | Available under [license](https://github.com/nsf/jsondiff/blob/master/LICENSE) +* [logrus](github.com/sirupsen/logrus) | Available under [license](https://github.com/sirupsen/logrus/blob/master/LICENSE) +* [golang](github.com/skratchdot/open-golang) | Available under [license](https://github.com/skratchdot/open-golang/blob/master/LICENSE) +* [testify](github.com/stretchr/testify) | Available under [license](https://github.com/stretchr/testify/blob/master/LICENSE) +* [uuid](github.com/twinj/uuid) | Available under [license](https://github.com/twinj/uuid/blob/master/LICENSE) +* [cli](github.com/urfave/cli) | Available under [license](https://github.com/urfave/cli/blob/master/LICENSE) + +* [BBolt](https://pkg.go.dev/go.etcd.io/bbolt/?tab=doc) | Available under [license](https://pkg.go.dev/go.etcd.io/bbolt?tab=licenses#LICENSE) +* [testify.v1](https://gopkg.in/stretchr/testify.v1) | Available under [license](https://github.com/stretchr/testify/blob/master/LICENSE) diff --git a/Changelog.md b/Changelog.md new file mode 100644 index 00000000..fc273061 --- /dev/null +++ b/Changelog.md @@ -0,0 +1,788 @@ +# ProtonMail Bridge Changelog + +Changelog [format](http://keepachangelog.com/en/1.0.0/) + +## Unpublished + + +### Changed +* Adding DSN Sentry as build time parameter + +## [v1.2.6] Donghai - beta (2020-03-XXX) + +### Added +* GODT-145 support drafts + * GODT-211,GODT-231 fix updating subject and other fields + * GODT-220 fix deleting drafts + * GODT-224 fix creating draft from outlook without sender + * GODT-230,GODT-232 fix constructing sender address for drafts + * sync already synced draft to newly created drafts mailbox + * Add Subject to EventMessageUpdated in pmapi +* GODT-37 Add body and TLS handshake timeouts +* GODT-90 implement DOH (DNS over HTTPS) proxy +* Noninteractive mode + + +### Changed +* bump version go-1.14 +* Bump dependencies: +| Repo | Old Version | New Version | +| github.com/0xAX/notificator | v0.0.0-20161214074916-82e921414e03 | v0.0.0-20191016112426-3962a5ea8da1 | +| github.com/ProtonMail/go-autostart | v0.0.0-20171017232241-85d98b097aae | v0.0.0-20181114175602-c5272053443a | +| github.com/abiosoft/ishell | v0.0.0-20171224170712-50251d04cb42 | v2.0.0+incompatible | +| github.com/emersion/go-sasl | v0.0.0-20161116183048-7e096a0a6197 | v0.0.0-20191210011802-430746ea8b9b | +| github.com/fatih/color | v1.7.0 | v1.9.0 | +| github.com/golang/mock | v1.4.2 | v1.4.3 | +| github.com/google/go-cmp | v0.3.1 | v0.4.0 | +| github.com/jaytaylor/html2text | v0.0.0-20190408195923-01ec452cbe43 | v0.0.0-20200220170450-61d9dc4d7195 | +| github.com/jhillyerd/enmime | v0.7.0 | v0.8.0 | +| github.com/logrusorgru/aurora | v0.0.0-20190803045625-94edacc10f9b | v0.0.0-20200102142835-e9ef32dff381 | +| github.com/skratchdot/open-golang | v0.0.0-20160302144031-75fb7ed4208c | v0.0.0-20200116055534-eef842397966 | +| github.com/stretchr/testify | v1.4.0 | v1.5.1 | +| github.com/therecipe/qt | v0.0.0-20191022233421-590f404884c9 | v0.0.0-20200126204426-5074eb6d8c41 | +| github.com/urfave/cli | v1.19.1 | v1.22.3 | + +* pkg/updates: closing File reader to avoid too many opened files during update +* Created monorepo with bridge, pmapi, bridge utils, mime and srp + * One lint config for all packages and lint fixes in the code + * Fix tests for bridge utils to work on MacOS + * All tests use testify framework + * Processed TODOs or created issues + * Cleanup up comments +* GODT-169 reduce the number of keyring unlocks +* CSB-40 return error instead of panic in credential store +* #577 Avoid multiple send +* GODT-39 Sync is paging per message ID with ability to continue after interrupted sync +* Panic handler used in store for event loop and sync +* GODT-109 merge only 50 events into one +* Use v1.0.16 of pmapi +* GODT-236 requests to /messages/{read,unread,delete,undelete,label,unlabel} are paged with up to 100 message IDs + +### Fixed +* GODT-227 Mitigate potential crash due to using a logged out pmapi client (proper fix to come in emma release) +* UserIDs were not checked when importing to Sent folder (affects copying from account1/sent to account2/sent) + + +## [v1.2.5] Charles - live (2020-03-11) beta (from 2020-02-10) + +### Hotfix +* CSB-40 panic in credential store +* keyring unlocking locker +* no panic on failed html parse +* too many open files + +### Added +* GODT-112 migration of preferences from c10 to c11 +* GODT-100 test for external internal ID when appending to Sent folder to return APPEND UID otherwise skip with no error +* GODT-43 connection troubleshooting modal +* GODT-55 uid support in fake api +* GODT-88 increase uid validity on switch mode +* #951 Implementation of IMAP extension UIDPLUS +* #964 New store package, see Changed section + +### Removed +* MOVE IMAP extension due to missing interaction with UIDPLUS + +### Changed +* GODT-88 run mbox sync in parallel when switch password mode (re-init not user) +* GODT-95 do not throw error when trying to create new mailbox in IMAP root +* GODT-75 do not fail on unlabel inside delete +* #1095 always delete IMAP USER including wrong pasword +* unique pmapi client userID (including #1098) +* using go.enmime@v0.6.1 snapshot +* better detection of non-auth-error +* reset `hasAuthChannel` during logout for proper login functionality (set up auth channel and unlock keys) +* allow `APPEND` messages without parsable email address in sender field +* #1060 avoid `Append` after internal message ID was found and message was copyed to mailbox using `MessageLabel` +* #1049 Basic usage of store in SMTP package to poll event loop during sending message +* #1050 pollNow waits for events to be processed +* #1047 Fix fetch of empty mailbox +* #1046 Fix removing mailbox counts +* #1048 For any message build error return custom message +* When event loop exits with error it logs out user from Bridge +* #953 #984 First label messages before unlabeling when moving messages +* fixes after refactor: + * Slight memory optimization + * #1043 do not stuck in loop for updating message which does not exist anywhere anymore + * #1034 fix UID dynamic range for empty list + * fix of sequence number in IMAP IDLE expunge during deleting messages + * #1030 #1028 Fix some crashes and bad auths + * #953 #984 label messages first during moving them +* #964 (and #769,#899,#918,#930,#931,#949) refactor of IMAP + - Fix of sequence number in IMAP IDLE expunge during deleting messages + - Fix not-returning empty result for UID dynamic range as said in RFC + - Separated IMAP to store and IMAP + - Store is responsible for everything about db and calls to pmapi, including event loop, sync, address mode + - IMAP is responsible only for IMAP interfaces + - Event loop is only one per ProtonMail account (instead of one per alias) + - It also means only one database per account (instead of one per address) + - Changing address mode is not destroying database, only buckets with IDs mapping (keeping metadata for account) + - Before first sync we set event ID so we will not miss changes happening during sync + - Thanks to previous point we are not starting new sync when we finish first one because of unprocessed events + - Sync is not blocking event loop (user can get new messages even during sync) + - Sync is not blocking reading operations (user can list mailboxes even before first sync is done) + - Sync is not blocking writing operations such as mark messages read/unread and so on + - Most operations have to be passed to API and only event loop is writing them to the database + - Avoid relying on counts API endpoint; use event counts as much as possible + - Separate function for storing message content type and header into database + - Sequence number optimised for last item in mailbox + - Allow sending IMAP idle update to timeout to avoid blocking bridge + - Synchronisation will create a label if not yet present + - Labels and Folders (including system folders) are stored in DB together with their counts for offline read-out + - AddressIDs for all user addresses are stored in DB + - IMAP updates channel is set when an IMAP client connects (and IMAP updates are dropped until then) + - DB keeps track of address mode (split/combined) +* Event loop starts as soon as user is initialised (i.e. logged in), not just when imap is connected +* Use pmapi v1.0.13 +* Logout user if initialisation fails +* Send UserRefreshEvent on user login and logout +* Use godog v0.8.0 under new name 'cucumber' (instead of DATA-DOG) + +### Fixed +* #1057 Logging in to an already logged in user would display unrelated error "invalid mailbox password" +* #1056 Changing mailbox password sometimes didn't log out user +* #1066 Split address mode can not work when credentials store is cleared +* #1071 Bridge can think it is in combined mode when actually it's in split mode +* Missing `enmime` dependency +* Issue where a failed sync was not attempted again +* Removing an address would crash bridge +* #1087 Accounts with capital letters could not be added +* #1087 Inactive addresses were not filtered out of the store +* #1087 Unlock with correct key if message is sent to alias and not primary (aka original) address +* #1109 Receiving an event referencing an address that isn't present could crash bridge +* Avoid concurrent map writes in imap backend +* GODT-103 User keys were not unlocked later if they were not unlocked during startup + + +## [v1.2.4] Brooklyn beta (2019-12-16) + +### Added +* #976: fix slow authentication + * Server security setting in info (GUI, CLI) + * default SSL for SMTP based on Mac version + * GUI/CLI items to controls SMTP security setup + * set new security and restart + +### Changed +* #961 Timeouts for go-pmapi client with http.Transport +* Event poll with no change will hang forever. Using separate goroutine and timeout instead of proper fix (will be in refactor) +* Fixed an issue where entering an in-use port multiple times via the CLI would make bridge use it. +* Update therecipe/qt and Qt to 5.13 + +## [v1.2.3] Akashi - live (2019-11-05) beta (2019-10-22) + +### Added +* #963 report first-start metric with bridge version +* #941 report new-login metric, report daily heartbeat +* #921 remote key lookup via Web Key Directory (WKD) +* #919 TLS issue notification in CLI + +### Changed +* #769 #930 #931 #949 Syncing messages and fetching message and attachments in parallel with five workers +* #956 #741 update keychain +* Re-download and re-unlock user keyring when addresses are changed +* #944 Ugrade go-pmapi dependency to v1.0.4 to support phase one of the key migration +* #683 Password rehides each time password entry screen is shown +* Import-Export#219 fix double parameter definition +* Upgrade go-pm-bridge-utils dependency to v1.0.1 +* #935 Fix wrong download link for linux updates. +* #952 fix error when sending mail with only BCC recipients (use empty slice instead of nil slice) +* Refactor `generateSendingInfo` to simplify logic; add test for this method. +* Generate code-coverage report with `make code-coverage` +* #942 fix focus window with logout message when trying to connect from the client +* Do not use panic for second instance +* #928 do not hide 'no keychain' dialog when upgrade is needed +* sending `NO` for errors while `FETCH` +* #899 Upgrade from Bolt to BBolt +* Upgrade to gopenpgp +* Bridge utils in own repository +* Code made compatible with name changes in go-pmapi + + +## [v1.2.2] - beta and live 2019-09-06 + +### Changed +* User compare case insensitive + +## [v1.2.1] - beta and live 2019-09-05 + +### Changed +* #924 fix start of bridge without internet connection + +## [v1.2.0] - beta 2019-08-22 + +### Added +* #903 added http.Client timeout to not hang out forever +* closing body after checking internet connection +* pedantic lint for bridgeUtils +* selected events are buffered and emited again when frontend loop is ready +* #890 implemented 2FA endpoint (auth split) +* #888 TLS Cert + * error bar and modal with explanation in GUI + * signal to show error + * add pinning to bridge (only for live API builds) +* #887 #883: + * wait before clearing data + * configer which provides pmapi.ClientConfig and app directories +* #861 restart after clear data +* panic handler for all goroutines +* CD for linux +* #798 + * check counts after sync + * update counts in all mailboxes after sync + * `db.Mailbox.RemoveMissing`, `db.Mailbox.PutMany` + * `util.NotImplemented` + * tests for sync +* bridge core tests: + * introduced interfaces: `pmapiClienterFactory`, `pmapiClienter`, `credentialStorer` + * automatic mock generation for `listener.Listener`, `bridge.pmapiClienter`, `bridge.credentialStorer` +* #818 REFACTOR: see doc/code-structure.md + * Tests for bridge core & utils + * update user before `GetQuota` + * http bridge API +* bridge core tests: + * introduced interfaces: `pmapiClienterFactory`, `pmapiClienter`, `credentialStorer` + * automatic mock generation for `listener.Listener`, `bridge.pmapiClienter`, `bridge.credentialStorer` +* #774 start initialization with sync immediately after login + +### Removed +* using `PutMeta` for DB to not rewrite header and size +* `Timeout` for connection (keep only `DialTimeout`) +* #798 `imapMailbox.sync` +* #818 REFACTOR: see doc/code-structure.md + * bridge global functions `GetAuth`, `GetAuthInfo`, `GetUserSettings` (using member functions of `pmapi.Client` instead) + * `backend.setCache`: not used + * IMAP extension for `XSTOP` and `XFOCUS` + * keychain `Disconnected` is not used, deleting directly (not using hide) + * `apiIdFrom(uid bool, id uint32)`, `apiIdRangeFromSeq(uid bool, seq imap.Seq)`: not used + * `server/dial.go` not used + * util `CustomMessage`, `StartTicker` not used + +### Changed +* check before first even sync +* do sync in parallel from events +* closing event loop by CloseConnectionEvent +* allow client to log in with address only +* fix IMAP users lock +* #646 download headers when needed for first time +* #895 fix of parsing address list +* #844 do not spam GUI with logout events & sleep after bad login attempt from the client +* #887 #883 #898 #902 logout account from API and all IMAP connections before clearing cache for account +* #882 unassign PMAPI client after logout and force to run garbage collector +* #880, #884, #885, #886 fix of informing user about outgoing non-encrypted e-mail +* #838 `Sirupsen` -> `sirupsen` +* #893 save panic report file everytime +* #880 fix of informing user about outgoing non-encrypted e-mail +* fix aliases in split mode +* fix decrypted data in log notification +* #471 fix of using font awesome in regular text +* `SearchMessage` all IDs from DB not depends on `totalOnAPI` +* #798 populate efficiently + * improved `imap.db.mailbox.Counts()` + * `mbox.total,unread` -> `mbox.totalOnAPI,unreadOnAPI` + * always show DB status (even for `IDLE` updates) + * `imapUser.sync` now takes `labelID` as parameter + * split population by 1000 messages + * `db.User.put(msgs,keepCache)` is used in sync to not overwrite `msg.Size` and `msg.Header` in DB + * separate sync function from `backend.labelMailbox` class + * `UidNext` uses bolt sequence value instead of cursor position +* `util.tests.go` moved to `bridgeUtils` +* #471 fix of using font awesome in regular text +* #818 REFACTOR: see doc/code-structure.md + * No global states/variables anymore + * Code separated from one big package into smaller packages (bridge core, utils, IMAP, SMTP, API) + * Bridge core completely refactored - core should be API over credentials store and PMAPI + * Configuration and preferences are on one place; passed as dependency to all packages + * Bridge utils separated from the rest of the bridge code to be used in Import/Export + * Many channels converted into one listener which can register listeners and emit events to them + * Each package is ready to be used with interfaces for possibility of mocking + * Removed IMAP extension XFOCUS, used bridge local API instead + * Removed IMAP extension XSTOP + * Sentry is not used in dev environment + * Logs are not cleared after start, clearing is triggered by `watchLogFileSize()` instead + * Log path changed one folder level up i.e. from `.../protonmail/bridge/c10` to `.../protonmail/bridge` + * Always cleared malformed keychain records + * Set credentials version on each `Put` + * `util.WriteHeader` -> `imap.writeHeader` + * save `message.ExternalID` for every client not just AppleMail + * server errors reported to frontend by common event listener +* Handle logout in event loop + + +## [v1.1.6] - 2019-07-09 (beta 2019-07-01) + +### Added +* #841 assume text/plain during sending e-mails when missing content type +* #805 list the new package links in upgrade dialog for linux +* #802 report the list errors to sentry +* #508 content related header fields for mail are saved in DB inside `msg.Header` +* #508 `doNotCacheError` to decide whether to rebuild message +* CI with lint check +* build flag `nogui` +* dummy html interface + +### Removed +* #508 content type rewrite on `GetHeader` +* #508 content type on custom message + +### Changed +* #854 avoid `nil` header and bodystructure on fail (as regression of #508) +* sanitize version in json file +* #850 keep correct main and body headers for import (as regression of #508) +* #841 choose parent ID only when there is exactly one message with external ID +* #811 #proton/backend-communication#11 go-pmapi!57 uid fixed +* update Qt 5.11.3 to 5.12.0 +* using gomodules instead of glide +* #508 use MIMEType and attachments to choose correct `Content-Type` +* #508 custom message replaces body before header is created +* #508 main header has `Content-Type` only after message was fully fetched +* #770 ignore empty key from data card and support multiple keys for contacts +* Build tags for simpler build of beta and QA builds. +* lint corrections + + +## [v1.1.5] - 2019-05-23 (beta 2019-05-23, 2019-05-16) + +### Changed +* fix custom message format +* #802 acumulated long lines while parsing body structure +* process `AddressEvent` before `MessageEvent` +* #791 updated crypto: fix wrong signature format +* #793 fix returning size +* #706 improved internet connection checking +* #771 updated raven, crypto, pmapi +* #792 use `INFO` as basic log level +* only one crash from second instance +* during event `MessageID` in log as field + +## [v1.1.4 live] - 2019-04-10 (beta 2019-04-05, 2019-03-27) + +### Added +* Address with port to IMAP debug +* #750 `backend/events.Manager.LastEvent` +* #750 `backend.user.areAllEventsProcessed` +* #750 Wait with message events until all related mailboxes are synchronized +* Restart limit to 10 +* Release string to raven + +### Changed +* #748 when charset missing assume utf8 and check the validity +* #750 before sync check that events are uptodate, if not poll events instead of sync +* Use pmapi with support of decrypted access token +* #750 Status is using DB status instead of API +* Format panic error as string instead of struct dump +* Validity of local certificate to increased to 20 years + +### Removed +* #750 Synchronization after 450 messages + +## [v1.1.3] - 2019-03-04 + +### Added +* sentry crash reporting in main +* program arguments to turn of CPU and memory profiling +* full version of program visible on release notes + +### Changed +* #720 only one concurent DB sync +* #720 sync every 3 pages +* #512 extending list of charsets go-pm-mime!4 + +## [v1.1.2] - beta only 2019-02-21 + +### Changed +* #512 fail on unknown charset +* #729 #733 visitor for MIME parsing + +## [v1.1.1] - 2019-02-11 +### Added +* #671 include `name` param in attachment `Content-Type` (in addition to `Content-Disposition` param `filename`) +* #671 do not include content headers for section requests e.g. `BODY.PEEK[2]` +* version info checks for newer version (do not show dialog when older is online) +* #592 new header `X-Pm-Conversation-Id` and also added to `References` +* #666 invoke `panic` while adding account `jakubqa+crash@protonmail.com` +* #592 new header fields `X-Pm-Date` storing m.Time and `X-Pm-External-Id` storing m.ExternalID +* #484 search criteria `Unkeyword` support + +### Changed +* fix srp modulus issue with new `ProtonMail/crypto` +* generate version files from main file +* be able to set update set on build +* #597 check on start that certificat will be still valid after one month and generate new cert if not +* #597 extended certificate validity to 2 years +* copyright 2019 +* exclude `protontech` repos from credits +* refactor of `go-pmapi`, `go-pm-crypto`, `go-pm-mime` and `go-srp` +* re-signed pubkey key +* version, revision and build time is set in main +* #666 use `bytes.Reader` instead of `bytes.Buffer` +* #666 clear unused buffers in body structure map +* No API request for fetch `body[header]` +* Added crash file counter to pass log tests +* #484 search fully relies on DB information (no need to reach API) +* #592 `parsingHeader` allows negative time (before 1.1.1970) +* #592 add original header first and then rewrite +* #592 `Message-Id` rewritten only if not present +* #592 rename `X-Internal-Id` to `X-Pm-Internal-Id` +* #592 internal references are added only when not present already +* #592 field `Date` changed to m.Time only when wrong format or missing `Date` +* #645 pmapi#26 `Message.Flags` instead of `IsEncrypted`, `Type`, `IsReplied`, `IsRepliedAll`, `IsForwarded` +* DB: do not allow to put Body or Attachements to db +* #574 SMTP: can now send more than one email +* #671 Verbosity levels: `debug` (only bridge), `debug-client` (bridge and client communication), `debug-server` (bridge, whole SMTP/IMAP communication) +* #644 Return rfc.size 0 or correct size of fetched body (stored in DB) +* #671 API requests URI in debug logs +* #625 Fix search results for Flagged and Unflagged +* Draft optimization fetch header +* #656 Fix sending of calendar invite on Outlook on MacOS +* #46 Allowed to run multiple instances, once per user + +### Removed +* makefile clean up old deploy code + +### Release notes +• Support multiple Bridge instances running in parallel (one per user) + +### Fixed bugs +• SMTP stays authenticated after sent message +• Reduce memory, processor and number of API calls + +## [v1.1.0] - 2018-10-22 + +### Removed +* `go-pmapi.Config.ClientSecret` +* `go-pmapi.PublicKey.Send` +* program argument `main` +* `backend.getMIMEMessageBodySection` (use `message.BodySection`) +* `backend.getSize` (use `message.BodySection`) + +### Added +* IMAP server: more info when write/send/flush error occurs #648 +* linux package paths inside version-json +* draggable popup windows for outgoing non-encrypted message #519 +* pmapi able to receive plain accessToken go-pmapi#23 #604 +* DB debug switch +* clearing message cache when db is cleared +* debug string to tests +* mime tree section parsing and test +* start ticker +* dump DB status +* `backend.ApplicationOutdated()` mechanism: once triggered logout all email clients. On try to login say _application outdated_ +* Force upgrade event (send from event loop) +* new systray with error symbol (used in mac for force update) +* test for upgrade +* GUI for upgrade +* add native upgrade to updates +* dial timeout client +* custom `copyRecursively` function +* when there is fresh version on start show release notes +* keychain helper using GNU pass +* error message on missing keychain + +### Changed +* imap `SEARCH` loops until all messages are listed #581 +* cached message timestamp is renewed on load +* message cache ID is userID+messageID +* private cache and added bodystructure +* Remove addresses from `m.ToList` that were not requested in SMTP `TO` +* IsFirstStart setup before loading Gui. Set it to false right after (don't wait till quit) +* check `eventMessage` not nil before address check +* `util.EventChannel` refactor: `SendEvent`->`Send` and new `SendEvent(EventCode)` +* Information bar keeps on once app is outdated +* Error dialog for upgrade has option for force upgrade +* IsFirstStart setup before loading Gui. Set false right after (don't wait till quit) +* pmapi: access token decrypted only if needed +* move `updates` from `frontend` to `util` +* move `CheckInternetConnection()` to `util` +* makefile clean up and change scripts for building +* reorganized updates +* start with new versioning + + 1.1.0 + | | `--- bug fix number (internal, irregular, beta relases) + | `----- minor version (features, release once per month, live release, milestones) + `------- major version (big changes, once per year, breaking changes, api force upgrade) + +* upgrade restart option in qt-frontend +* GOOS save functions +* windows update procedure +* darwin update procedure +* `zip` replaced by `tgz` +* using move instead of write truncated +* linux dependencies (pass and gnome-keychain optional) +* `Store.helper` -> `Store.secrets` + +### Release notes +• New self-update procedure +• Changed restarting mechanism +• Support for GNU pass for linux +• Various GUI improvements + +### Fixed bugs +• RFC complaint SEARCH and FETCH responses +• Additional synchronization of mail database + + +## [v1.0.6 silent] - 2018-08-23 +### Added +* new svg icon in linux package + +## [v1.0.6] - 2018-08-09 + +### Added +* `backend.GetUserSettings()` + +### Changed +* related to Desktop-Bridge#561 +* api flag to build scripts +* `BodyKey` and `AttachmentKey` contains `Key` and `Algorithm` +* `event.User.Addresses` -> `event.Addresses` +* `user.Addresses` -> `client.Addresses()` +* typos and fixes +* pmapi update +* `backend.configClient` -> `backend.authClient` +* `auth.Uid` -> `auth.Uid()` +* `keyRingForAddress()` -> `Client.KeyRingForAddressID()` +* `Message.IsRead` -> `Message.Unread` +* `pmapi.User.Unlock()` -> `pmapi.Client.UnlockAddresses()` +* `TwoFactor` -> `HasTwoFactor()` and `PasswordMode` -> `HasMailboxPassword()` +* icon to match ImportExport + + +### Release notes +• Removed deprecated API routes + +### Fixed bugs +• Frequent Thunderbird timeout +• SMTP requests not case-sensitive + +## [v1.0.5] - 2018-07-12 + +### Added +* UpdateCurrentAgent from lastMailClient +* current OS +* use Qt to set nice OS with version +* all `client.Do` errors are interpreted as connection issue +* moved to internal gitlab +* typo `frontend-qml` +* better message for case when server is not reacheable +* Setting 1min timeout to IMAP connection + +### Changed +* password: click2show, click2hide +* notification in bug report window +* less frequent version check +* closing ticker + +### Removed +* sockets and unused libraries + +### Release notes +* Improved response of IMAP server +* Sending requests with client information +* Less frequent notification about new version + +### Fixed bugs +* Support of Outlook calendar event format +* Too many opened file descriptors issue +* Fixed 7bit MIME issue while sending + + +## [v1.0.4] - 2018-05-15 + +### Changed +* version files available at both download and static +* MIME `text/calendar` are parsed as attachment +* UserID as identifier in keychain and pmapi token +* Keychain format and function refactor +* Create crash file on panic with full trace +* Clear old data only in main process (no double keychain typing) +* Create label udpate API route +* Selectable text in release notes + +### Added +* Support sending to external PGP recipients +* Return error codes: `0: Ok`, `2: Frontend crashed`, `3: Bridge already running`, `4: Uknown argument`, `42: Restart application` + +### Release notes +* Support of encryption to external PGP recipients using contacts created on beta.protonmail.com (see https://protonmail.com/blog/pgp-vulnerability-efail/ to understand the vulnerabilities that may be associated with sending to other PGP clients) +* Notification that outgoing email will be delivered as non-encrypted +* NOTE: Due to a change of the keychain format, you will need to add your account(s) to the Bridge after installing this version + +### Bugs fixed +* Support accounts with same user names +* Support sending vCalendar event + +## [v1.0.3] - 2018-03-26 +* All from silent updates plus following + +### Changed +* Okay -> "Remind me later" +* Imported message with `text/html` body was imported as `text/plain` +* Reload cache when labeling Seen/Unseen +* Merge with Import-Export branch + * Inheritable Bug report window + * Common functions: WriteHeader (parse PM mail) and CustomMessage (when incorrect keys) + * Updates refactor + * Bug report window + * Checkbox and with label (only I/E) + * Error dialog and Info tooltip (only I/E) + * Add user modal formating (colors, text) + * Account view style + * Input box style (used in bug report) + * Input field style (used in add account and change port) + * Added style variables for I/E + * Tab button style + * Window bar style and functionality (closing / minimize window) + +### Release notes +* Improved responsiveness in the UI + +### Fixed bugs +* Fixed some formatting issues with imports +* Fixed port changing via CLI + +## Silent update - 2018-03-13 + +### Changed +* Remove firewall error message + + +## [v1.0.2] - 2018-03-12 +* All from silent updates plus following + +### Added +* UTF-7 support +* Message when communication between bridge and email client is blocked by firewall (Windows only) + +### Changed +* Added gnome-keyring dejavu fonts into linux dependency +* Corrected parentID when reply/forward: taken from `protonmail.internalid` reference +* Update user object in backend after unlock to apply address changes + +### Release notes +* UTF7 encoding support for older imported emails + +### Fixed bugs +* Fixed issues with conversation threading +* Support for multiple "ReplyTo" addresses +* Fixed issue where some address updates would not register immediately + + + +## [v1.0.1-4 (linux only)] Silent deploy - 2018-02-28 + +### Changed +* More similar look of window title bar to Windows 10 style. +* Qt 5.10 Button Controls 2 conflict (`icon`->`iconText`) + +### Added +* Linux default font +* Multiple reply-to addresses support (also API) +* Command line interface +* Credits are generated automatically from glide.lock +* Created script to build linux packages (dep,rpm,PKGBUILD) +* Correct config folders with env variable `$XDG_CONFIG_HOME` + +### Fixed bugs +* Clearing global cache +* Default linux font problems +* Support Reply-To multiple addresses + +### Release notes +* Improved visual appearance for win and linux + + + +## [v1.0.1] Silent deploy - 2017-12-30 + +### Changed +* Fixed bug with parsing address list (CC became BCC) + + + +## [v1.0.1] - 2017-12-20 + +### Added +* When current log file is more than 10MB open new one, checked every 15min +* Keep only last three log files including current one, triggered every start and when switching log files +* Translation context +* Accessibility objects for button and static text +* All objects are accessible including focus scope for modals and messages +* Automatically fill the email client in bug report form +* Catch corrupted MacOS keychain error and show the link to FAQ +* Unlabel message +* Update emptying and filtering routes +* Parse the address comment as defined in RFC + +### Changed +* Default log level set to Warning +* Info logs during adding account and connecting client promoted to warning level +* Log only when email client was changed (previously logged on every assign) +* Force upgrade bubble notification only when requested by API +* Don't show warning systray icon when "You have then newest version!" bubble message is showed +* Header date format RFC822Z -> RFC1123Z +* IMAP ID and QUOTA responses forced to use quoted strings (fixing SparkMail issue) +* Avoid AddressChanged bubble when no address was changed + +### Release notes +* Reduced log file size and log file history +* Accessibility support for MacOS VoiceOver and Windows Narrator +* Improved notification system +* Supported imports with older address format + + + +## [v1.0.0] - 2017-12-06 + +### Added +* Encoding support of message body, title items, attachment name, for all standard charsets +* Force update API message handled as new version event + +### Changed +* Refactor `bridge-qtfronted` -> `frontend` +* Only one main file and basic support of CLI (not finished) +* Common QML package `ProtonUI`, which is used by `BridgeUI` and `ImportExportUI` +* ChangedUser signal contain address and event type to distinguish between logout, internet off/on, address_change +* API address changed event handled gracefully (if not possible, logout) +* Update mac keychain (should resolve problem with adding new account to bridge, NOT CONFIRMED) +* Solved hanging GUI on keychain error (should solve all win-7, no-gui errors) +* New systray icons for Mac (black and white no background) +* GUI cosmetics: + - "Click here to start" triangle position + - Wrong cursor type on link + - Create main window before notification + +### Release notes +* Better notification when new version is needed or when account address is changed. +* Encoding support for the standard charsets. +* Improved visual appearance. + +### Fixed bugs +* Fixed missing GUI for Windows with empty keychain. + + + +## Changelog format +* Changelog [format](http://keepachangelog.com/en/1.0.0/) + +### Guiding Principles +* Changelogs are for humans, not machines. +* There should be an entry for every single version. +* The same types of changes should be grouped. +* Versions and sections should be linkable. +* The latest version comes first. +* The release date of each version is displayed. +* Mention whether you follow Semantic Versioning. + +### Types of changes +* `Added` for new features. +* `Changed` for changes in existing functionality. +* `Deprecated` for soon-to-be removed features. +* `Removed` for now removed features. +* `Fixed` for any bug fixes. +* `Security` in case of vulnerabilities. +* additional for in app release notes + * `Release notes` in case of vulnerabilities. + * `Fixed bugs` in case of vulnerabilities. + diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..810fce6e --- /dev/null +++ b/LICENSE @@ -0,0 +1,621 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..81125818 --- /dev/null +++ b/Makefile @@ -0,0 +1,208 @@ +export GO111MODULE=on +GOOS:=$(shell go env GOOS) + +## Build +.PHONY: build check-has-go + +VERSION?=1.2.6-git +REVISION:=$(shell git rev-parse --short=10 HEAD) +BUILD_TIME:=$(shell date +%FT%T%z) + +BUILD_TAGS?=pmapi_prod +BUILD_FLAGS:=-tags='${BUILD_TAGS}' +BUILD_FLAGS_NOGUI:=-tags='${BUILD_TAGS} nogui' +GO_LDFLAGS:=$(addprefix -X main.,Version=${VERSION} Revision=${REVISION} BuildTime=${BUILD_TIME}) +ifneq "${BUILD_LDFLAGS}" "" + GO_LDFLAGS+= ${BUILD_LDFLAGS} +endif +GO_LDFLAGS:=-ldflags '${GO_LDFLAGS}' +BUILD_FLAGS+= ${GO_LDFLAGS} +BUILD_FLAGS_NOGUI+= ${GO_LDFLAGS} + +DEPLOY_DIR:=cmd/Desktop-Bridge/deploy +ICO_FILES:= +EXE:=$(shell basename ${CURDIR}) + +ifeq "${GOOS}" "windows" + EXE+=.exe + ICO_FILES:=logo.ico icon.rc icon_windows.syso +endif +ifeq "${GOOS}" "darwin" + DARWINAPP_CONTENTS:=${DEPLOY_DIR}/darwin/${EXE}.app/Contents + EXE:=Contents/Macos/${EXE} +endif +EXE_TARGET:=${DEPLOY_DIR}/${GOOS}/${EXE} +TGZ_TARGET:=bridge_${GOOS}_${REVISION}.tgz + +build: ${TGZ_TARGET} + +${TGZ_TARGET}: ${DEPLOY_DIR}/${GOOS} + rm -f $@ + cd ${DEPLOY_DIR} && tar czf ../../../$@ ${GOOS} + +${DEPLOY_DIR}/linux: ${EXE_TARGET} + cp -pf ./internal/frontend/share/icons/logo.svg ${DEPLOY_DIR}/linux/ + cp -pf ./LICENSE ${DEPLOY_DIR}/linux/ + cp -pf ./Changelog.md ${DEPLOY_DIR}/linux/ + +${DEPLOY_DIR}/darwin: ${EXE_TARGET} + cp ./internal/frontend/share/icons/Bridge.icns ${DARWINAPP_CONTENTS}/Resources/ + cp -r "utils/addcert.scpt" ${DARWINAPP_CONTENTS}/Resources/ + cp LICENSE ${DARWINAPP_CONTENTS}/Resources/ + rm -rf "${DARWINAPP_CONTENTS}/Frameworks/QtWebEngine.framework" + rm -rf "${DARWINAPP_CONTENTS}/Frameworks/QtWebView.framework" + rm -rf "${DARWINAPP_CONTENTS}/Frameworks/QtWebEngineCore.framework" + +${DEPLOY_DIR}/windows: ${EXE_TARGET} + cp ./internal/frontend/share/icons/logo.ico ${DEPLOY_DIR}/windows/ + +${EXE_TARGET}: check-has-go gofiles ${ICO_FILES} update-vendor + rm -rf deploy ${GOOS} ${DEPLOY_DIR} + cp cmd/Desktop-Bridge/main.go . + qtdeploy ${BUILD_FLAGS} build desktop + mv deploy cmd/Desktop-Bridge + rm -rf ${GOOS} main.go + +logo.ico: ./internal/frontend/share/icons/logo.ico + cp $^ . +icon.rc: ./internal/frontend/share/icon.rc + cp $^ . +./internal/frontend/qt/icon_windows.syso: ./internal/frontend/share/icon.rc logo.ico + windres $< $@ +icon_windows.syso: ./internal/frontend/qt/icon_windows.syso + cp $^ . + + +## Rules for therecipe/qt +.PHONY: prepare-vendor update-vendor +THERECIPE_QTVER:=$(shell grep "github.com/therecipe/qt " go.mod | sed -r 's;.* v[0-9\.]+-[0-9]+-([a-f0-9]*).*;\1;') +THERECIPE_ENV:=github.com/therecipe/env_${GOOS}_amd64_513 + +# vendor folder will be deleted by gomod hence we cache the big repo +# therecipe/env in order to download it only once +vendor-cache/${THERECIPE_ENV}: + git clone https://${THERECIPE_ENV}.git vendor-cache/${THERECIPE_ENV} + +LINKCMD:=ln -sf ${CURDIR}/vendor-cache/${THERECIPE_ENV} vendor/${THERECIPE_ENV} +ifeq "${GOOS}" "windows" + WINDIR:=$(subst /c/,c:\\,${CURDIR})/vendor-cache/${THERECIPE_ENV} + LINKCMD:=cmd //c 'mklink $(subst /,\,vendor\${THERECIPE_ENV} ${WINDIR})' +endif + +prepare-vendor: + go install -v -tags=no_env github.com/therecipe/qt/cmd/... + go mod vendor + +# update-vendor is PHONY because we need to make sure that we always have updated vendor +update-vendor: vendor-cache/${THERECIPE_ENV} prepare-vendor + ${LINKCMD} + + +## Dev dependencies +.PHONY: install-devel-tools install-linter install-go-mod-outdated +LINTVER:="v1.23.6" +LINTSRC:="https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh" + +install-dev-dependencies: install-devel-tools install-linter install-go-mod-outdated + +install-devel-tools: check-has-go + go get -v github.com/golang/mock/gomock + go get -v github.com/golang/mock/mockgen + go get -v github.com/go-delve/delve + +install-linter: check-has-go + curl -sfL $(LINTSRC) | sh -s -- -b $(shell go env GOPATH)/bin $(LINTVER) + +install-go-mod-outdated: + which go-mod-outdated || go get -u github.com/psampaz/go-mod-outdated + + +## Checks, mocks and docs +.PHONY: check-has-go check-license test bench coverage mocks lint updates doc +check-has-go: + @which go || (echo "Install Go-lang!" && exit 1) + +check-license: + find . -not -path "./vendor/*" -not -name "*mock*.go" -regextype posix-egrep -regex ".*\.go|.*\.qml" -exec grep -L "Copyright (c) 2020 Proton Technologies AG" {} \; + +test: gofiles + @# Listing packages manually to not run Qt folder (which needs to run qtsetup first) and integration tests. + go test -coverprofile=/tmp/coverage.out -run=${TESTRUN} \ + ./internal/api/... \ + ./internal/bridge/... \ + ./internal/events/... \ + ./internal/frontend/autoconfig/... \ + ./internal/frontend/cli/... \ + ./internal/imap/... \ + ./internal/preferences/... \ + ./internal/smtp/... \ + ./internal/store/... \ + ./pkg/... + +bench: + go test -run '^$$' -bench=. -memprofile bench_mem.pprof -cpuprofile bench_cpu.pprof ./internal/store + go tool pprof -png -output bench_mem.png bench_mem.pprof + go tool pprof -png -output bench_cpu.png bench_cpu.pprof + +coverage: test + go tool cover -html=/tmp/coverage.out -o=coverage.html + +mocks: + mockgen --package mocks github.com/ProtonMail/proton-bridge/internal/bridge Configer,PreferenceProvider,PanicHandler,PMAPIProvider,CredentialsStorer > internal/bridge/mocks/mocks.go + mockgen --package mocks github.com/ProtonMail/proton-bridge/internal/store PanicHandler,BridgeUser > internal/store/mocks/mocks.go + mockgen --package mocks github.com/ProtonMail/proton-bridge/pkg/listener Listener > internal/store/mocks/utils_mocks.go + +lint: + which golangci-lint || $(MAKE) install-linter + golangci-lint run ./... + +updates: install-go-mod-outdated + # Uncomment the "-ci" to fail the job if something can be updated. + go list -u -m -json all | go-mod-outdated -update -direct #-ci + +doc: + godoc -http=:6060 + +.PHONY: gofiles +# Following files are for the whole app so it makes sense to have them in bridge package. +# (Options like cmd or internal were considered and bridge package is the best place for them.) +gofiles: ./internal/bridge/credits.go ./internal/bridge/release_notes.go +./internal/bridge/credits.go: ./utils/credits.sh go.mod + cd ./utils/ && ./credits.sh +./internal/bridge/release_notes.go: ./utils/release-notes.sh ./release-notes/notes.txt ./release-notes/bugs.txt + cd ./utils/ && ./release-notes.sh + + +## Run and debug +.PHONY: run run-qt run-qt-cli run-nogui run-nogui-cli run-debug qmlpreview qt-fronted-clean clean +VERBOSITY?=debug-client +RUN_FLAGS:=-m -l=${VERBOSITY} + +run: run-nogui-cli + +run-qt: ${EXE_TARGET} + PROTONMAIL_ENV=dev ./$< ${RUN_FLAGS} | tee last.log +run-qt-cli: ${EXE_TARGET} + PROTONMAIL_ENV=dev ./$< ${RUN_FLAGS} -c + +run-nogui: clean-vendor gofiles + PROTONMAIL_ENV=dev go run ${BUILD_FLAGS_NOGUI} cmd/Desktop-Bridge/main.go ${RUN_FLAGS} | tee last.log +run-nogui-cli: clean-vendor gofiles + PROTONMAIL_ENV=dev go run ${BUILD_FLAGS_NOGUI} cmd/Desktop-Bridge/main.go ${RUN_FLAGS} -c + +run-debug: + PROTONMAIL_ENV=dev dlv debug --build-flags "${BUILD_FLAGS_NOGUI}" cmd/Desktop-Bridge/main.go -- ${RUN_FLAGS} + +run-qml-preview: + make -C internal/frontend/qt -f Makefile.local qmlpreview + +clean-frontend-qt: + make -C internal/frontend/qt -f Makefile.local clean + +clean-vendor: clean-frontend-qt + rm -rf ./vendor + +clean: clean-frontend-qt + rm -rf vendor-cache + rm -rf cmd/Desktop-Bridge/deploy + rm -f build last.log mem.pprof diff --git a/README.md b/README.md new file mode 100644 index 00000000..8d7255b6 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# ProtonMail Bridge +Copyright (c) 2020 Proton Technologies AG + +This repository holds the ProtonMail Bridge application. +For a detailed build information see [BUILD](./BUILD.md). +For licensing information see [COPYING](./COPYING.md). +For contribution policy see [CONTRIBUTING](./CONTRIBUTING.md). + +## Description +ProtonMail Desktop Bridge for e-mail clients. + +When launched, the servers will be started and a GUI will show up. From this GUI, +the server can be started and stopped and configuration for e-mail clients can +be generated. + +To configure an e-mail client, enter your ProtonMail credentials. Open your +e-mail client and add a new account with the settings which are displayed. +The client will be able to sync with your ProtonMail account only when the +bridge is started, so enabling it on startup is recommended. + +When the main window is closed, the bridge will continue to run in the +background. + +More details [on the public website](https://protonmail.com/bridge). + + +## Keychain +You need to have keychain in order to run the ProtonMail Bridge. On Mac or +Windows, Bridge uses native credential managers. On Linux, use +[Gnome keyring](https://wiki.gnome.org/Projects/GnomeKeyring/) +or +[pass](https://www.passwordstore.org/). + +## Environment Variables + +### Bridge application +- `BRIDGESTRICTMODE`: tells bridge to turn on `bbolt`'s "strict mode" which checks the database after every `Commit`. Set to `1` to enable. + +### Dev build or run +- `PROTONMAIL_ENV`: when set to `dev` it is not using Sentry to report crashes +- `VERBOSITY`: set log level used during test time and by the makefile. +- `VERSION`: set the bridge app version used during testing or building. + +### Integration testing +- `TEST_ENV`: set which env to use (fake or live) +- `TEST_ACCOUNTS`: set JSON file with configured accounts +- `TAGS`: set build tags for tests +- `FEATURES`: set feature dir, file or scenario to test + + + + diff --git a/ci/Dockerfile b/ci/Dockerfile new file mode 100644 index 00000000..21a7733f --- /dev/null +++ b/ci/Dockerfile @@ -0,0 +1,4 @@ +FROM gitlab.protontech.ch:4567/protonmail/ci-containers/go + +RUN apt-get -y update +RUN apt-get -y install openssh-client libsecret-1-dev libgl1-mesa-dev time diff --git a/cmd/Desktop-Bridge/main.go b/cmd/Desktop-Bridge/main.go new file mode 100644 index 00000000..acddd9a9 --- /dev/null +++ b/cmd/Desktop-Bridge/main.go @@ -0,0 +1,434 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package main + +/* + ___....___ + ^^ __..-:'':__:..:__:'':-..__ + _.-:__:.-:'': : : :'':-.:__:-._ + .':.-: : : : : : : : : :._:'. + _ :.': : : : : : : : : : : :'.: _ + [ ]: : : : : : : : : : : : : :[ ] + [ ]: : : : : : : : : : : : : :[ ] + :::::::::[ ]:__:__:__:__:__:__:__:__:__:__:__:__:__:[ ]::::::::::: + !!!!!!!!![ ]!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!![ ]!!!!!!!!!!! + ^^^^^^^^^[ ]^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^[ ]^^^^^^^^^^^ + [ ] [ ] + [ ] [ ] + jgs [ ] [ ] + ~~^_~^~/ \~^-~^~ _~^-~_^~-^~_^~~-^~_~^~-~_~-^~_^/ \~^ ~~_ ^ +*/ + +import ( + "fmt" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "runtime" + "runtime/pprof" + "strconv" + "strings" + + "github.com/ProtonMail/proton-bridge/internal/api" + "github.com/ProtonMail/proton-bridge/internal/bridge" + "github.com/ProtonMail/proton-bridge/internal/bridge/credentials" + "github.com/ProtonMail/proton-bridge/internal/events" + "github.com/ProtonMail/proton-bridge/internal/frontend" + "github.com/ProtonMail/proton-bridge/internal/imap" + "github.com/ProtonMail/proton-bridge/internal/pmapifactory" + "github.com/ProtonMail/proton-bridge/internal/preferences" + "github.com/ProtonMail/proton-bridge/internal/smtp" + "github.com/ProtonMail/proton-bridge/pkg/args" + "github.com/ProtonMail/proton-bridge/pkg/config" + "github.com/ProtonMail/proton-bridge/pkg/listener" + "github.com/ProtonMail/proton-bridge/pkg/updates" + "github.com/allan-simon/go-singleinstance" + "github.com/getsentry/raven-go" + "github.com/sirupsen/logrus" + "github.com/urfave/cli" +) + +// cacheVersion is used for cache files such as lock, events, preferences, user_info, db files. +// Different number will drop old files and create new ones. +const cacheVersion = "c11" + +// Following variables are set via ldflags during build. +var ( + // Version of the build. + Version = "" //nolint[gochecknoglobals] + // Revision is current hash of the build. + Revision = "" //nolint[gochecknoglobals] + // BuildTime stamp of the build. + BuildTime = "" //nolint[gochecknoglobals] + // AppShortName to make setup + AppShortName = "bridge" //nolint[gochecknoglobals] + // DSNSentry client keys to be able to report crashes to Sentry + DSNSentry = "" //nolint[gochecknoglobals] +) + +var ( + longVersion = Version + " (" + Revision + ")" //nolint[gochecknoglobals] + buildVersion = longVersion + " " + BuildTime //nolint[gochecknoglobals] + + log = config.GetLogEntry("main") //nolint[gochecknoglobals] + + // How many crashes in a row. + numberOfCrashes = 0 //nolint[gochecknoglobals] + // After how many crashes bridge gives up starting. + maxAllowedCrashes = 10 //nolint[gochecknoglobals] +) + +func main() { + if err := raven.SetDSN(DSNSentry); err != nil { + log.WithError(err).Errorln("Can not setup sentry DSN") + } + raven.SetRelease(Revision) + bridge.UpdateCurrentUserAgent(Version, runtime.GOOS, "", "") + + args.FilterProcessSerialNumberFromArgs() + filterRestartNumberFromArgs() + + app := cli.NewApp() + app.Name = "Protonmail Bridge" + app.Version = buildVersion + app.Flags = []cli.Flag{ + cli.StringFlag{ + Name: "log-level, l", + Usage: "Set the log level (one of panic, fatal, error, warn, info, debug, debug-client, debug-server)"}, + cli.BoolFlag{ + Name: "no-window", + Usage: "Don't show window after start"}, + cli.BoolFlag{ + Name: "cli, c", + Usage: "Use command line interface"}, + cli.BoolFlag{ + Name: "noninteractive", + Usage: "Start Bridge entirely noninteractively"}, + cli.StringFlag{ + Name: "version-json, g", + Usage: "Generate json version file"}, + cli.BoolFlag{ + Name: "mem-prof, m", + Usage: "Generate memory profile"}, + cli.BoolFlag{ + Name: "cpu-prof, p", + Usage: "Generate CPU profile"}, + } + app.Usage = "ProtonMail IMAP and SMTP Bridge" + app.Action = run + + // Always log the basic info about current bridge. + logrus.SetLevel(logrus.InfoLevel) + log.WithField("version", Version). + WithField("revision", Revision). + WithField("runtime", runtime.GOOS). + WithField("build", BuildTime). + WithField("args", os.Args). + WithField("appLong", app.Name). + WithField("appShort", AppShortName). + Info("Run app") + if err := app.Run(os.Args); err != nil { + log.Error("Program exited with error: ", err) + } +} + +type panicHandler struct { + cfg *config.Config + err *error // Pointer to error of cli action. +} + +func (ph *panicHandler) HandlePanic() { + r := recover() + if r == nil { + return + } + + config.HandlePanic(ph.cfg, fmt.Sprintf("Recover: %v", r)) + frontend.HandlePanic() + + *ph.err = cli.NewExitError("Panic and restart", 666) + numberOfCrashes++ + log.Error("Restarting after panic") + restartApp() + os.Exit(666) +} + +// run initializes and starts everything in a precise order. +// +// IMPORTANT: ***Read the comments before CHANGING the order *** +func run(context *cli.Context) (contextError error) { // nolint[funlen] + // We need to have config instance to setup a logs, panic handler, etc ... + cfg := config.New(AppShortName, Version, Revision, cacheVersion) + + // We want to know about any problem. Our PanicHandler calls sentry which is + // not dependent on anything else. If that fails, it tries to create crash + // report which will not be possible if no folder can be created. That's the + // only problem we will not be notified about in any way. + panicHandler := &panicHandler{cfg, &contextError} + defer panicHandler.HandlePanic() + + // First we need config and create necessary folder; it's dependency for everything. + if err := cfg.CreateDirs(); err != nil { + log.Fatal("Cannot create necessary folders: ", err) + } + + // Setup of logs should be as soon as possible to ensure we record every wanted report in the log. + logLevel := context.GlobalString("log-level") + debugClient, debugServer := config.SetupLog(cfg, logLevel) + + // Should be called after logs are configured but before preferences are created. + migratePreferencesFromC10(cfg) + + if err := cfg.ClearOldData(); err != nil { + log.Error("Cannot clear old data: ", err) + } + + // Doesn't make sense to continue when Bridge was invoked with wrong arguments. + // We should tell that to the user before we do anything else. + if context.Args().First() != "" { + _ = cli.ShowAppHelp(context) + return cli.NewExitError("Unknown argument", 4) + } + + // It's safe to get version JSON file even when other instance is running. + // (thus we put it before check of presence of other Bridge instance). + updates := updates.New(AppShortName, Version, Revision, BuildTime, bridge.ReleaseNotes, bridge.ReleaseFixedBugs, cfg.GetUpdateDir()) + if dir := context.GlobalString("version-json"); dir != "" { + generateVersionFiles(updates, dir) + return nil + } + + // ClearOldData before starting new bridge to do a proper setup. + // + // IMPORTANT: If you the change position of this you will need to wait + // until force-update to be applied on all currently used bridge + // versions + if err := cfg.ClearOldData(); err != nil { + log.Error("Cannot clear old data: ", err) + } + + // GetTLSConfig is needed for IMAP, SMTL and local bridge API (to check second instance). + // + // This should be called after ClearOldData, in order to re-create the + // certificates if clean data will remove them (accidentally or on purpose). + tls, err := config.GetTLSConfig(cfg) + if err != nil { + log.WithError(err).Fatal("Cannot get TLS certificate") + } + + pref := preferences.New(cfg) + + // Now we can try to proceed with starting the bridge. First we need to ensure + // this is the only instance. If not, we will end and focus the existing one. + lock, err := singleinstance.CreateLockFile(cfg.GetLockPath()) + if err != nil { + log.Warn("Bridge is already running") + if err := api.CheckOtherInstanceAndFocus(pref.GetInt(preferences.APIPortKey), tls); err != nil { + numberOfCrashes = maxAllowedCrashes + log.Error("Second instance: ", err) + } + return cli.NewExitError("Bridge is already running.", 3) + } + defer lock.Close() //nolint[errcheck] + + // In case user wants to do CPU or memory profiles... + if doCPUProfile := context.GlobalBool("cpu-prof"); doCPUProfile { + f, err := os.Create("cpu.pprof") + if err != nil { + log.Fatal("Could not create CPU profile: ", err) + } + if err := pprof.StartCPUProfile(f); err != nil { + log.Fatal("Could not start CPU profile: ", err) + } + defer pprof.StopCPUProfile() + } + + if doMemoryProfile := context.GlobalBool("mem-prof"); doMemoryProfile { + defer makeMemoryProfile() + } + + // Now we initialize all Bridge parts. + log.Debug("Initializing bridge...") + eventListener := listener.New() + events.SetupEvents(eventListener) + + credentialsStore, credentialsError := credentials.NewStore() + if credentialsError != nil { + log.Error("Could not get credentials store: ", credentialsError) + } + + pmapiClientFactory := pmapifactory.New(cfg, eventListener) + + bridgeInstance := bridge.New(cfg, pref, panicHandler, eventListener, Version, pmapiClientFactory, credentialsStore) + imapBackend := imap.NewIMAPBackend(panicHandler, eventListener, cfg, bridgeInstance) + smtpBackend := smtp.NewSMTPBackend(panicHandler, eventListener, pref, bridgeInstance) + + go func() { + defer panicHandler.HandlePanic() + apiServer := api.NewAPIServer(pref, tls, cfg.GetTLSCertPath(), cfg.GetTLSKeyPath(), eventListener) + apiServer.ListenAndServe() + }() + + go func() { + defer panicHandler.HandlePanic() + imapPort := pref.GetInt(preferences.IMAPPortKey) + imapServer := imap.NewIMAPServer(debugClient, debugServer, imapPort, tls, imapBackend, eventListener) + imapServer.ListenAndServe() + }() + + go func() { + defer panicHandler.HandlePanic() + smtpPort := pref.GetInt(preferences.SMTPPortKey) + useSSL := pref.GetBool(preferences.SMTPSSLKey) + smtpServer := smtp.NewSMTPServer(debugClient || debugServer, smtpPort, useSSL, tls, smtpBackend, eventListener) + smtpServer.ListenAndServe() + }() + + // Decide about frontend mode before initializing rest of bridge. + var frontendMode string + + switch { + case context.GlobalBool("cli"): + frontendMode = "cli" + case context.GlobalBool("noninteractive"): + frontendMode = "noninteractive" + default: + frontendMode = "qt" + } + + log.WithField("mode", frontendMode).Debug("Determined frontend mode to use") + + // If we are starting bridge in noninteractive mode, simply block instead of starting a frontend. + if frontendMode == "noninteractive" { + <-(make(chan struct{})) + return nil + } + + showWindowOnStart := !context.GlobalBool("no-window") + frontend := frontend.New(Version, buildVersion, frontendMode, showWindowOnStart, panicHandler, cfg, pref, eventListener, updates, bridgeInstance, smtpBackend) + + // Last part is to start everything. + log.Debug("Starting frontend...") + if err := frontend.Loop(credentialsError); err != nil { + log.Error("Frontend failed with error: ", err) + return cli.NewExitError("Frontend error", 2) + } + + if frontend.IsAppRestarting() { + restartApp() + } + + return nil +} + +// migratePreferencesFromC10 will copy preferences from c10 folder to c11. +// It will happen only when c10/prefs.json exists and c11/prefs.json not. +// No configuration changed between c10 and c11 versions. +func migratePreferencesFromC10(cfg *config.Config) { + pref10Path := config.New(AppShortName, Version, Revision, "c10").GetPreferencesPath() + if _, err := os.Stat(pref10Path); os.IsNotExist(err) { + log.WithField("path", pref10Path).Trace("Old preferences does not exist, migration skipped") + return + } + + pref11Path := cfg.GetPreferencesPath() + if _, err := os.Stat(pref11Path); err == nil { + log.WithField("path", pref11Path).Trace("New preferences already exists, migration skipped") + return + } + + data, err := ioutil.ReadFile(pref10Path) //nolint[gosec] + if err != nil { + log.WithError(err).Error("Problem to load old preferences") + return + } + + err = ioutil.WriteFile(pref11Path, data, 0644) + if err != nil { + log.WithError(err).Error("Problem to migrate preferences") + return + } + + log.Info("Preferences migrated") +} + +// generateVersionFiles writes a JSON file with details about current build. +// Those files are used for upgrading the app. +func generateVersionFiles(updates *updates.Updates, dir string) { + log.Info("Generating version files") + for _, goos := range []string{"windows", "darwin", "linux"} { + log.Debug("Generating JSON for ", goos) + if err := updates.CreateJSONAndSign(dir, goos); err != nil { + log.Error(err) + } + } +} + +func makeMemoryProfile() { + name := "./mem.pprof" + f, err := os.Create(name) + if err != nil { + log.Error("Could not create memory profile: ", err) + } + if abs, err := filepath.Abs(name); err == nil { + name = abs + } + log.Info("Writing memory profile to ", name) + runtime.GC() // get up-to-date statistics + if err := pprof.WriteHeapProfile(f); err != nil { + log.Error("Could not write memory profile: ", err) + } + _ = f.Close() +} + +// filterRestartNumberFromArgs removes flag with a number how many restart we already did. +// See restartApp how that number is used. +func filterRestartNumberFromArgs() { + tmp := os.Args[:0] + for i, arg := range os.Args { + if !strings.HasPrefix(arg, "--restart_") { + tmp = append(tmp, arg) + continue + } + var err error + numberOfCrashes, err = strconv.Atoi(os.Args[i][10:]) + if err != nil { + numberOfCrashes = maxAllowedCrashes + } + } + os.Args = tmp +} + +// restartApp starts a new instance in background. +func restartApp() { + if numberOfCrashes >= maxAllowedCrashes { + log.Error("Too many crashes") + return + } + if exeFile, err := os.Executable(); err == nil { + arguments := append(os.Args[1:], fmt.Sprintf("--restart_%d", numberOfCrashes)) + cmd := exec.Command(exeFile, arguments...) //nolint[gosec] + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + if err := cmd.Start(); err != nil { + log.Error("Restart failed: ", err) + } + } +} diff --git a/doc/bridge.md b/doc/bridge.md new file mode 100644 index 00000000..b521dd0b --- /dev/null +++ b/doc/bridge.md @@ -0,0 +1,135 @@ +# Bridge + +## Main blocks + +This is basic overview of the main bridge blocks. + +Note connection between IMAP/SMTP and PMAPI. IMAP and SMTP packages are in the queue to be refactored +and we would like to try to have functionality in bridge core or bridge utilities (such as messages) +than direct usage of PMAPI from IMAP or SMTP. Also database (BoltDB) should be moved to bridge core. + +```mermaid +graph LR + S[Server] + C[Client] + U[User] + + subgraph "Bridge app" + Core[Bridge core] + API[PMAPI] + Store + DB[BoltDB] + Frontend["Qt / CLI"] + IMAP + SMTP + + IMAP --> Store + IMAP --> Core + SMTP --> Core + SMTP --> API + Core --> API + Core --> Store + Store --> API + Store --> DB + Frontend --> Core + + end + + C --> IMAP + C --> SMTP + U --> Frontend + API --> S +``` + +## Code structure + +More detailed graph of main types used in Bridge app and connection between them. Here is already +communication to PMAPI only from bridge core which is not true, yet. IMAP and SMTP are still calling +PMAPI directly. + +```mermaid +graph TD + + C["Client (e.g. Thunderbird)"] + PM[ProtonMail Server] + + subgraph "Bridge app" + subgraph "Bridge core" + B[Bridge] + U[User] + + B --> U + end + + subgraph Store + StoreU[Store User] + StoreA[Address] + StoreM[Mailbox] + + StoreU --> StoreA + StoreA --> StoreM + end + + subgraph Credentials + CredStore[Store] + Creds[Credentials] + + CredStore --> Creds + end + + subgraph Frontend + CLI + Qt + end + + subgraph IMAP + IB[IMAP backend] + IA[IMAP address] + IM[IMAP mailbox] + + IB --> B + IB --> IA + IA --> IM + IA --> U + IA --> StoreA + IM --> StoreM + end + + subgraph SMTP + SB[SMTP backend] + SS[SMTP session] + + SB --> B + SB --> SS + SS --> U + end + end + + subgraph PMAPI + AC[Client] + end + + C --> IB + C --> SB + + CLI --> B + Qt --> B + + U --> CredStore + U --> Creds + + U --> StoreU + + StoreU --> AC + StoreA --> AC + StoreM --> AC + + B --> AC + U --> AC + + AC --> PM +``` + +## How to debug + +Run `make run-debug` which starts [Delve](https://github.com/go-delve/delve). diff --git a/doc/communication.md b/doc/communication.md new file mode 100644 index 00000000..212257dc --- /dev/null +++ b/doc/communication.md @@ -0,0 +1,114 @@ +# Communication + +## First login and sync + +When user logs in to the bridge for the first time, immediatelly starts the first sync. +First sync downloads all headers of all e-mails and creates database to have proper UIDs +and indexes for IMAP. See [database](database.md) for more information. + +By default, whenever it's possible, sync downloads only all e-mails maiblox which already +have list of labels so we can construct all mailboxes (inbox, sent, trash, custom folders +and lables) without need to download each e-mail headers many times. + +Note that we need to download also bodies to calculate size of the e-mail and set proper +content type (clients uses content type for guess if e-mail contains attachment)--but only +body, not attachment. Also it's downloaded only for the first time. After that we store +those information in our database so next time we only sync headers, labels and so on. + +First sync takes some time. List of 150 messages takes about second and then we need to +download bodies for each message. We still need to do some optimalizations. Anyway, if +user has reasonable amount of e-mails, there is good chance user will see e-mails in the +client right after adding account. + +When account is added to client, client start the sync. This sync will ask Bridge app +for all headers (done quickly) and then starts to download all bodies and attachment. +Unfortunatelly for some e-mail more than once if the same e-mail is in more mailboxes +(e.g. inbox and all mail)--there is no way to tell over IMAP it's the same message. + +After successful login of client to IMAP, Bridge starts event loop. That periodicly ask +servers (each 30 seconds) for new updates (new message, keys, …). + +```mermaid +sequenceDiagram + participant S as Server + participant B as Bridge + participant C as Client + + Note right of B: Set up PM account
by user + + loop First sync + B ->> S: Fetch body and attachements + Note right of B: Build local database
(e-mail UIDs) + end + + Note right of C: Set up IMAP/SMTP
by user + + C ->> B: IMAP login + B ->> S: Authenticate user + Note right of B: Create IMAP user + + loop Event loop, every 30 sec + B ->> S: Fetch e-mail headers + B ->> C: Send IMAP IDLE response + end + + C ->> B: IMAP LIST directories + + loop Client sync + C ->> B: IMAP SELECT directory + C ->> B: IMAP SEARCH e-mails UIDs + C ->> B: IMAP FETCH of e-mail UID + B ->> S: Fetch body and attachements + Note right of B: Decrypt message
and attachement + B ->> C: IMAP response + end +``` + +## IMAP IDLE extension + +IMAP IDLE is extension, it has to be supported by both client and server. IMAP server (in our case +the bridge) supports it so clients can use it. It works by issuing `IDLE` command by the client and +keeps the connection open. When the server has some update, server (the bridge) will respond to that +by `EXISTS` (new message), `APPEND` (imported message), `EXPUNGE` (deleted message) or `MOVE` response. + +Even when there is connection with IDLE open, server can mark the client as inactive. Therefore, +it's recommended the client should reissue the connection after each 29 minutes. This is not the +real push and can fail! + +Our event loop is also simple pull and it will trigger IMAP IDLE when we get some new update from +the server. Would be good to have push from the server, but we need to wait for the support on API. + +RFC: https://tools.ietf.org/html/rfc2177 + +```mermaid +sequenceDiagram + participant S as Server + participant B as Bridge + participant C as Client + + C ->> B: IMAP IDLE + + loop Every 30 seconds + S ->> B: Checking events + B ->> C: IMAP response + end +``` + +## Sending e-mails + +E-mail are sent over standard SMTP protocol. Our bridge takes the message, encrypts and sent it +further to our server which will then send the message to its final destination. The important +and tricky part is encryption. See [encryption](encryption.md) or [PMEL document](https://docs.google.com/document/d/1lEBkG0DC5FOWlumInKtu4a9Cc1Eszp48ZhFy9UpPQso/edit) +for more information. + +```mermaid +sequenceDiagram + participant S as Server + participant B as Bridge + participant C as Client + + C ->> B: SMTP send e-mail + Note right of B: Encrypt messages + B ->> S: Send encrypted e-mail + B ->> C: Respond OK +``` diff --git a/doc/database.md b/doc/database.md new file mode 100644 index 00000000..33a01b0d --- /dev/null +++ b/doc/database.md @@ -0,0 +1,27 @@ +# Database + +Bridge needs to have a small database to pair our IDs with IMAP UIDs and indexes. IMAP protocol +requires every message to have an unique UID in mailbox. In this context, mailbox is not an account, +but a folder or label. This means that one message can have more UIDs, one for each mailbox (folder), +and that two messages can have the same UID, but each for different mailbox (folder). + +IMAP index is just an index. Look at it like to an array: `["UID1", "UID2", "UID3"]`. We can access +message by UID or index; for example index 2 and UID `UID2`. When this message is deleted, we need +to re-index all following messages. The array will look now like `["UID1", "UID3"]` and the last +message can be accessed by index 2 or UID `UID3`. + +See RFCs for more information: + +* https://tools.ietf.org/html/rfc822 +* https://tools.ietf.org/html/rfc3501 + +Our database is currently built on BBolt and have those buckets (key-value storage): + +* Message metadata bucket: + + * `[metadataBucket][API_ID] -> pmapi.Message{subject, from, to, size, other headers...}` (without body or attachment) + +* Mapping buckets + + * `[mailboxesBucket][addressID-mailboxID][api_ids][API_ID] -> UID` + * `[mailboxesBucket][addressID-mailboxID][imap_ids][UID] -> API_ID` diff --git a/doc/encryption.md b/doc/encryption.md new file mode 100644 index 00000000..4786068b --- /dev/null +++ b/doc/encryption.md @@ -0,0 +1,12 @@ +# Encryption + +Encryption is done in PMAPI, bridge utils and bridge itself. The best would be to keep encryption +in PMAPI and bridge utils (in pacakge such as messages). All packages are using our high-level +GopenPGP library on top of openpgp. + +## `gopenpgp.KeyRing` + +We use one `KeyRing` per address. Our usage then contains all keys for specific address. Primary +key is always on the first position, then there old ones to be able to decrypt last e-mail. +Openpgp encrypts given message with all available keys, so we need to first get first (primary) +key for encryption to have message encrypted only once with primary key. diff --git a/doc/index.md b/doc/index.md new file mode 100644 index 00000000..84ee9e52 --- /dev/null +++ b/doc/index.md @@ -0,0 +1,9 @@ +# Documentation + +Documentation pages in order to read for a novice: + +* [Development cycle](development.md) +* [Bridge code](bridge.md) +* [Internal Bridge database](database.md) +* [Communication between Bridge, Client and Server](communication.md) +* [Encryption](encryption.md) diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..7ecf9e40 --- /dev/null +++ b/go.mod @@ -0,0 +1,77 @@ +module github.com/ProtonMail/proton-bridge + +go 1.13 + +// These dependencies are `replace`d below, so the version numbers should be ignored. +// They are in a separate require block to highlight this. +require ( + github.com/docker/docker-credential-helpers v0.0.0-00010101000000-000000000000 + github.com/emersion/go-imap v0.0.0-20171113213225-939ec3994dbe + github.com/emersion/go-imap-quota v0.0.0-20171113212021-e883a2bc54d6 + github.com/emersion/go-smtp v0.0.0-20180712174835-db5eec195e67 + github.com/jameskeane/bcrypt v0.0.0-20170924085257-7509ea014998 +) + +require ( + github.com/0xAX/notificator v0.0.0-20191016112426-3962a5ea8da1 + github.com/ProtonMail/go-appdir v1.0.0 + github.com/ProtonMail/go-apple-mobileconfig v0.0.0-20160701194735-7ea9927a11f6 + github.com/ProtonMail/go-autostart v0.0.0-20181114175602-c5272053443a + github.com/ProtonMail/go-imap-id v0.0.0-20171219160728-ed0baee567ee + github.com/ProtonMail/go-vcard v0.0.0-20180326232728-33aaa0a0c8a5 + github.com/ProtonMail/gopenpgp v1.0.1-0.20190912180537-d398098113ed + github.com/abiosoft/ishell v2.0.0+incompatible + github.com/abiosoft/readline v0.0.0-20180607040430-155bce2042db // indirect + github.com/allan-simon/go-singleinstance v0.0.0-20160830203053-79edcfdc2dfc + github.com/andybalholm/cascadia v1.1.0 + github.com/certifi/gocertifi v0.0.0-20200211180108-c7c1fbc02894 // indirect + github.com/chzyer/logex v1.1.10 // indirect + github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 // indirect + github.com/cucumber/godog v0.8.1 + github.com/danieljoos/wincred v1.0.2 // indirect + github.com/emersion/go-imap-appendlimit v0.0.0-20160923165328-beeb382f2a42 + github.com/emersion/go-imap-idle v0.0.0-20161227184850-e03ba1e0ed89 + github.com/emersion/go-imap-specialuse v0.0.0-20161227184202-ba031ced6a62 + github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b + github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe + github.com/emersion/go-vcard v0.0.0-20190105225839-8856043f13c5 // indirect + github.com/fatih/color v1.9.0 + github.com/flynn-archive/go-shlex v0.0.0-20150515145356-3f9db97f8568 // indirect + github.com/getsentry/raven-go v0.2.0 + github.com/go-resty/resty/v2 v2.2.0 + github.com/golang/mock v1.4.3 + github.com/google/go-cmp v0.4.0 + github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c // indirect + github.com/hashicorp/go-multierror v1.0.0 + github.com/jaytaylor/html2text v0.0.0-20200220170450-61d9dc4d7195 + github.com/jhillyerd/enmime v0.8.0 + github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 + github.com/keybase/go-keychain v0.0.0-20200218013740-86d4642e4ce2 + github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381 + github.com/miekg/dns v1.1.29 + github.com/myesui/uuid v1.0.0 // indirect + github.com/nsf/jsondiff v0.0.0-20190712045011-8443391ee9b6 + github.com/pkg/errors v0.9.1 + github.com/sirupsen/logrus v1.4.2 + github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 + github.com/stretchr/testify v1.5.1 + github.com/therecipe/qt v0.0.0-20200126204426-5074eb6d8c41 + github.com/therecipe/qt/internal/binding/files/docs/5.12.0 v0.0.0-20200126204426-5074eb6d8c41 // indirect + github.com/therecipe/qt/internal/binding/files/docs/5.13.0 v0.0.0-20200126204426-5074eb6d8c41 // indirect + github.com/twinj/uuid v1.0.0 // indirect + github.com/urfave/cli v1.22.3 + go.etcd.io/bbolt v1.3.3 + golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 + golang.org/x/net v0.0.0-20200301022130-244492dfa37a + golang.org/x/text v0.3.2 + gopkg.in/stretchr/testify.v1 v1.2.2 // indirect +) + +replace ( + github.com/docker/docker-credential-helpers => github.com/ProtonMail/docker-credential-helpers v1.0.0 + github.com/emersion/go-imap => github.com/ProtonMail/go-imap v0.0.0-20190327080220-0e686f0e855f + github.com/emersion/go-imap-quota => github.com/ProtonMail/go-imap-quota v0.0.0-20171219161528-20f0ba8904de + github.com/emersion/go-smtp => github.com/ProtonMail/go-smtp v0.0.0-20181206232543-8261df20d309 + github.com/jameskeane/bcrypt => github.com/ProtonMail/bcrypt v0.0.0-20170924085257-7509ea014998 + golang.org/x/crypto => github.com/ProtonMail/crypto v0.0.0-20190604143603-d3d8a14a4d4f +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..238dd6d3 --- /dev/null +++ b/go.sum @@ -0,0 +1,228 @@ +github.com/0xAX/notificator v0.0.0-20191016112426-3962a5ea8da1 h1:j9HaafapDbPbGRDku6e/HRs6KBMcKHiWcm1/9Sbxnl4= +github.com/0xAX/notificator v0.0.0-20191016112426-3962a5ea8da1/go.mod h1:NtXa9WwQsukMHZpjNakTTz0LArxvGYdPA9CjIcUSZ6s= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/ProtonMail/bcrypt v0.0.0-20170924085257-7509ea014998 h1:YT2uVwQiRQZxCaaahwfcgTq2j3j66w00n/27gb/zubs= +github.com/ProtonMail/bcrypt v0.0.0-20170924085257-7509ea014998/go.mod h1:HecWFHognK8GfRDGnFQbW/LiV7A3MX3gZVs45vk5h8I= +github.com/ProtonMail/crypto v0.0.0-20190604143603-d3d8a14a4d4f h1:cFhATQTJGK2iZ0dc+jRhr75mh6bsc5Ug6NliaBya8Kw= +github.com/ProtonMail/crypto v0.0.0-20190604143603-d3d8a14a4d4f/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +github.com/ProtonMail/docker-credential-helpers v1.0.0 h1:0DQXbZNvUszWgXUuP7TzvQdwnkK1D5Zf/glBgCFJFCk= +github.com/ProtonMail/docker-credential-helpers v1.0.0/go.mod h1:R1gQindzdYFcWJuuGXteYHDJzUCVtyU+EpEqp9aWcFs= +github.com/ProtonMail/go-appdir v1.0.0 h1:PZXQ0HkveuEugga3LeDycxWtybrXQfKR0ThxURd6ojw= +github.com/ProtonMail/go-appdir v1.0.0/go.mod h1:3d8Y9F5mbEUjrYbcJ3rcDxcWbqbttF+011nVZmdRdzc= +github.com/ProtonMail/go-apple-mobileconfig v0.0.0-20160701194735-7ea9927a11f6 h1:YsSJ/mvZFYydQm/hRrt8R8UtgETixN2y3LK98f5LT60= +github.com/ProtonMail/go-apple-mobileconfig v0.0.0-20160701194735-7ea9927a11f6/go.mod h1:EtDfBMIDWmVe4viZCuBTEfe3OIIo0ghbpOaAZVO+hVg= +github.com/ProtonMail/go-autostart v0.0.0-20181114175602-c5272053443a h1:fXK2KsfnkBV9Nh+9SKzHchYjuE9s0vI20JG1mbtEAcc= +github.com/ProtonMail/go-autostart v0.0.0-20181114175602-c5272053443a/go.mod h1:oTGdE7/DlWIr23G0IKW3OXK9wZ5Hw1GGiaJFccTvZi4= +github.com/ProtonMail/go-imap v0.0.0-20190327080220-0e686f0e855f h1:QkLm4yfhBQuBxrC46Vhy2sonOWVrwIJo5bgKpA82+TY= +github.com/ProtonMail/go-imap v0.0.0-20190327080220-0e686f0e855f/go.mod h1:+m2uLXghuYktgE/vc5AkmCxx1qhu33ZKHFWg1cGZPD0= +github.com/ProtonMail/go-imap-id v0.0.0-20171219160728-ed0baee567ee h1:Q/nK7A9xzUimAZsQDa/yaw3xW9PkTTnJnkT5wAkXrmI= +github.com/ProtonMail/go-imap-id v0.0.0-20171219160728-ed0baee567ee/go.mod h1:795VPXcRUIQ9JyMNHP4el582VokQfippgjkQP3Gk0r0= +github.com/ProtonMail/go-imap-quota v0.0.0-20171219161528-20f0ba8904de h1:+LA9teDYUwGkBvg0kqZPZetmxIv1r7s9/npBP1yzKs0= +github.com/ProtonMail/go-imap-quota v0.0.0-20171219161528-20f0ba8904de/go.mod h1:85zbnYVWIY7//iScX9fnB/kKOGH9B86YPqtpr7f1i7A= +github.com/ProtonMail/go-mime v0.0.0-20190521135552-09454e3dbe72 h1:hGCc4Oc2fD3I5mNnZ1VlREncVc9EXJF8dxW3sw16gWM= +github.com/ProtonMail/go-mime v0.0.0-20190521135552-09454e3dbe72/go.mod h1:NYt+V3/4rEeDuaev/zw1zCq8uqVEuPHzDPo3OZrlGJ4= +github.com/ProtonMail/go-smtp v0.0.0-20181206232543-8261df20d309 h1:2pzfKjhBjSnw3BgmfTYRFQr1rFGxhfhUY0KKkg+RYxE= +github.com/ProtonMail/go-smtp v0.0.0-20181206232543-8261df20d309/go.mod h1:6UoBvDAMA/cTBwS3Y7tGpKnY5RH1F1uYHschT6eqAkI= +github.com/ProtonMail/go-vcard v0.0.0-20180326232728-33aaa0a0c8a5 h1:Uga1DHFN4GUxuDQr0F71tpi8I9HqPIlZodZAI1lR6VQ= +github.com/ProtonMail/go-vcard v0.0.0-20180326232728-33aaa0a0c8a5/go.mod h1:oeP9CMN+ajWp5jKp1kue5daJNwMMxLF+ujPaUIoJWlA= +github.com/ProtonMail/gopenpgp v1.0.1-0.20190912180537-d398098113ed h1:3gib6hGF61VfRu7cqqkODyRUgES5uF/fkLQanPPJiO8= +github.com/ProtonMail/gopenpgp v1.0.1-0.20190912180537-d398098113ed/go.mod h1:NstNbZx1OIoyq+2qHAFLwDFpHbMk8L2i2Vr+LioJ3/g= +github.com/abiosoft/ishell v2.0.0+incompatible h1:zpwIuEHc37EzrsIYah3cpevrIc8Oma7oZPxr03tlmmw= +github.com/abiosoft/ishell v2.0.0+incompatible/go.mod h1:HQR9AqF2R3P4XXpMpI0NAzgHf/aS6+zVXRj14cVk9qg= +github.com/abiosoft/readline v0.0.0-20180607040430-155bce2042db h1:CjPUSXOiYptLbTdr1RceuZgSFDQ7U15ITERUGrUORx8= +github.com/abiosoft/readline v0.0.0-20180607040430-155bce2042db/go.mod h1:rB3B4rKii8V21ydCbIzH5hZiCQE7f5E9SzUb/ZZx530= +github.com/allan-simon/go-singleinstance v0.0.0-20160830203053-79edcfdc2dfc h1:mZca0/HZ/XWXP9txkfdl2GH6mUzBqAlyJz3u5Lg8fuA= +github.com/allan-simon/go-singleinstance v0.0.0-20160830203053-79edcfdc2dfc/go.mod h1:qqsTQiwdyqxU05iDCsi0oN3P4nrVxAmn8xCtODDSf/U= +github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo= +github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= +github.com/aslakhellesoy/gox v1.0.100/go.mod h1:AJl542QsKKG96COVsv0N74HHzVQgDIQPceVUh1aeU2M= +github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI= +github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8= +github.com/certifi/gocertifi v0.0.0-20200211180108-c7c1fbc02894 h1:JLaf/iINcLyjwbtTsCJjc6rtlASgHeIJPrB6QmwURnA= +github.com/certifi/gocertifi v0.0.0-20200211180108-c7c1fbc02894/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA= +github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/cucumber/gherkin-go/v11 v11.0.0 h1:cwVwN1Qn2VRSfHZNLEh5x00tPBmZcjATBWDpxsR5Xug= +github.com/cucumber/gherkin-go/v11 v11.0.0/go.mod h1:CX33k2XU2qog4e+TFjOValoq6mIUq0DmVccZs238R9w= +github.com/cucumber/godog v0.8.1 h1:lVb+X41I4YDreE+ibZ50bdXmySxgRviYFgKY6Aw4XE8= +github.com/cucumber/godog v0.8.1/go.mod h1:vSh3r/lM+psC1BPXvdkSEuNjmXfpVqrMGYAElF6hxnA= +github.com/cucumber/godog v0.9.0 h1:QOb8wyC7f+FVFXzY3RdgowwJUb4WeJfqbnQqaH4jp+A= +github.com/cucumber/godog v0.9.0/go.mod h1:roWCHkpeK6UTOyIRRl7IR+fgfBeZ4vZR7OSq2J/NbM4= +github.com/cucumber/messages-go/v10 v10.0.1/go.mod h1:kA5T38CBlBbYLU12TIrJ4fk4wSkVVOgyh7Enyy8WnSg= +github.com/cucumber/messages-go/v10 v10.0.3 h1:m/9SD/K/A15WP7i1aemIv7cwvUw+viS51Ui5HBw1cdE= +github.com/cucumber/messages-go/v10 v10.0.3/go.mod h1:9jMZ2Y8ZxjLY6TG2+x344nt5rXstVVDYSdS5ySfI1WY= +github.com/danieljoos/wincred v1.0.2 h1:zf4bhty2iLuwgjgpraD2E9UbvO+fe54XXGJbOwe23fU= +github.com/danieljoos/wincred v1.0.2/go.mod h1:SnuYRW9lp1oJrZX/dXJqr0cPK5gYXqx3EJbmjhLdK9U= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emersion/go-imap-appendlimit v0.0.0-20160923165328-beeb382f2a42 h1:3TeZ5gy3We/LVL0sqmGhM8dFDTSM7Hyj7PMIdl6OTs4= +github.com/emersion/go-imap-appendlimit v0.0.0-20160923165328-beeb382f2a42/go.mod h1:ikgISoP7pRAolqsVP64yMteJa2FIpS6ju88eBT6K1yQ= +github.com/emersion/go-imap-idle v0.0.0-20161227184850-e03ba1e0ed89 h1:AzbVhcrxgJO5MfSvzG5q4IfrYVm0Jw4AHNPz47+DiR0= +github.com/emersion/go-imap-idle v0.0.0-20161227184850-e03ba1e0ed89/go.mod h1:o14zPKCmEH5WC1vU5SdPoZGgNvQx7zzKSnxPQlobo78= +github.com/emersion/go-imap-specialuse v0.0.0-20161227184202-ba031ced6a62 h1:4ZAfwfc8aDlj26kkEap1UDSwwDnJp9Ie8Uj1MSXAkPk= +github.com/emersion/go-imap-specialuse v0.0.0-20161227184202-ba031ced6a62/go.mod h1:/nybxhI8kXom8Tw6BrHMl42usALvka6meORflnnYwe4= +github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b h1:uhWtEWBHgop1rqEk2klKaxPAkVDCXexai6hSuRQ7Nvs= +github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b/go.mod h1:G/dpzLu16WtQpBfQ/z3LYiYJn3ZhKSGWn83fyoyQe/k= +github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe h1:40SWqY0zE3qCi6ZrtTf5OUdNm5lDnGnjRSq9GgmeTrg= +github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= +github.com/emersion/go-vcard v0.0.0-20190105225839-8856043f13c5 h1:n9qx98xiS5V4x2WIpPC2rr9mUM5ri9r/YhCEKbhCHro= +github.com/emersion/go-vcard v0.0.0-20190105225839-8856043f13c5/go.mod h1:WIi9g8OKJQHXtQbx7GExlo6UAFaui9WDMYabJ+Be4WI= +github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= +github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= +github.com/flynn-archive/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BMXYYRWTLOJKlh+lOBt6nUQgXAfB7oVIQt5cNreqSLI= +github.com/flynn-archive/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:rZfgFAXFS/z/lEd6LJmf9HVZ1LkgYiHx5pHhV5DR16M= +github.com/getsentry/raven-go v0.2.0 h1:no+xWJRb5ZI7eE8TWgIq1jLulQiIoLG0IfYxv5JYMGs= +github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= +github.com/go-resty/resty/v2 v2.2.0 h1:vgZ1cdblp8Aw4jZj3ZsKh6yKAlMg3CHMrqFSFFd+jgY= +github.com/go-resty/resty/v2 v2.2.0/go.mod h1:nYW/8rxqQCmI3bPz9Fsmjbr2FBjGuR2Mzt6kDh3zZ7w= +github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw= +github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE= +github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gogo/protobuf v1.3.1 h1:DqDEcV5aeaTmdFBePNpYsp3FlcVH/2ISVVM9Qf8PSls= +github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561 h1:aBzukfDxQlCTVS0NBUjI5YA3iVeaZ9Tb5PxNrrIP1xs= +github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14= +github.com/golang/mock v1.4.3 h1:GV+pQPG/EUUbkh47niozDcADz6go/dUwhVzdUQHIVRw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/gopherjs/gopherjs v0.0.0-20190411002643-bd77b112433e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c h1:7lF+Vz0LqiRidnzC1Oq86fpX1q/iEv2KJdrCtttYjT4= +github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-version v1.0.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/jaytaylor/html2text v0.0.0-20190408195923-01ec452cbe43 h1:jTkyeF7NZ5oIr0ESmcrpiDgAfoidCBF4F5kJhjtaRwE= +github.com/jaytaylor/html2text v0.0.0-20190408195923-01ec452cbe43/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= +github.com/jaytaylor/html2text v0.0.0-20200220170450-61d9dc4d7195 h1:j0UEFmS7wSjAwKEIkgKBn8PRDfjcuggzr93R9wk53nQ= +github.com/jaytaylor/html2text v0.0.0-20200220170450-61d9dc4d7195/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= +github.com/jhillyerd/enmime v0.8.0 h1:PHc/2LXtnDmCDm0V4+5NlBx+MoubmufhuNXwpKSV2o8= +github.com/jhillyerd/enmime v0.8.0/go.mod h1:MBHs3ugk03NGjMM6PuRynlKf+HA5eSillZ+TRCm73AE= +github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA= +github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= +github.com/keybase/go-keychain v0.0.0-20200218013740-86d4642e4ce2 h1:1XZArHAPddaXKbg51etNbCjkNUkKgSa0s8dSz2LYB2g= +github.com/keybase/go-keychain v0.0.0-20200218013740-86d4642e4ce2/go.mod h1:JJNrCn9otv/2QP4D7SMJBgaleKpOf66PnW6F5WGNRIc= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381 h1:bqDmpDG49ZRnB5PcgP0RXtQvnMSgIF14M7CBd2shtXs= +github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= +github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGeM= +github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= +github.com/mattn/go-runewidth v0.0.4 h1:2BvfKmzob6Bmd4YsL0zygOqfdFnK7GR4QL06Do4/p7Y= +github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/miekg/dns v1.1.29 h1:xHBEhR+t5RzcFJjBLJlax2daXOrTYtr9z4WdKEfWFzg= +github.com/miekg/dns v1.1.29/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= +github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= +github.com/myesui/uuid v1.0.0 h1:xCBmH4l5KuvLYc5L7AS7SZg9/jKdIFubM7OVoLqaQUI= +github.com/myesui/uuid v1.0.0/go.mod h1:2CDfNgU0LR8mIdO8vdWd8i9gWWxLlcoIGGpSNgafq84= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/nsf/jsondiff v0.0.0-20190712045011-8443391ee9b6 h1:qsqscDgSJy+HqgMTR+3NwjYJBbp1+honwDsszLoS+pA= +github.com/nsf/jsondiff v0.0.0-20190712045011-8443391ee9b6/go.mod h1:uFMI8w+ref4v2r9jz+c9i1IfIttS/OkmLfrk1jne5hs= +github.com/olekukonko/tablewriter v0.0.1 h1:b3iUnf1v+ppJiOfNX4yxxqfWKMQPZR5yoh8urCTFX88= +github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca h1:NugYot0LIVPxTvN8n+Kvkn6TrbMyxQiuvKdEwFdR9vI= +github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= +github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= +github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/therecipe/qt v0.0.0-20200126204426-5074eb6d8c41 h1:yBVcrpbaQYJBdKT2pxTdlL4hBE/eM4UPcyj9YpyvSok= +github.com/therecipe/qt v0.0.0-20200126204426-5074eb6d8c41/go.mod h1:SUUR2j3aE1z6/g76SdD6NwACEpvCxb3fvG82eKbD6us= +github.com/twinj/uuid v1.0.0 h1:fzz7COZnDrXGTAOHGuUGYd6sG+JMq+AoE7+Jlu0przk= +github.com/twinj/uuid v1.0.0/go.mod h1:mMgcE1RHFUFqe5AfiwlINXisXfDGro23fWdPUfOMjRY= +github.com/urfave/cli v1.22.3 h1:FpNT6zq26xNpHZy08emi755QwzLPs6Pukqjlc7RfOMU= +github.com/urfave/cli v1.22.3/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +go.etcd.io/bbolt v1.3.3 h1:MUGmc65QhB3pIlaQ5bB4LwqSj6GIonVJXpZiaKNyaKk= +go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190420063019-afa5a82059c6/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80 h1:Ao/3l156eZf2AW5wK8a7/smtodRU+gha3+BeqJ69lRk= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0 h1:MsuvTghUPjX762sGLnGsxC3HM0B5r83wEtYcYR8/vRs= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a h1:GuSPYbZzB5/dcLNCwLQLsg3obCJtX9IJhpXkvY7kzk0= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190419153524-e8e3143a4f4a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe h1:6fAMxZRR6sl1Uq8U61gxU+kPTs2tR8uOySCbBP7BN/M= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190420181800-aa740d480789/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191216052735-49a3e744a425 h1:VvQyQJN0tSuecqgcIxMWnnfG5kSmgy9KZR9sW3W5QeA= +golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/stretchr/testify.v1 v1.2.2 h1:yhQC6Uy5CqibAIlk1wlusa/MJ3iAN49/BsR/dCCKz3M= +gopkg.in/stretchr/testify.v1 v1.2.2/go.mod h1:QI5V/q6UbPmuhtm10CaFZxED9NreB8PnFYN9JcR6TxU= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/icon.iconset/icon_128x128.png b/icon.iconset/icon_128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..a4bbbd9740eb5cfe57deb823abb933382599c578 GIT binary patch literal 3433 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 BYfAtC literal 0 HcmV?d00001 diff --git a/icon.iconset/icon_128x128@2x.png b/icon.iconset/icon_128x128@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..d12764ae1a5bf254dde19cc5ff878842e6cc85d4 GIT binary patch literal 6986 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 literal 0 HcmV?d00001 diff --git a/icon.iconset/icon_16x16.png b/icon.iconset/icon_16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..9ed58081bf36405bf72bdcef9e79965eafd68118 GIT binary patch literal 945 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbKJOS+@4BLl<6e(pbstU$g(vPY0F z14ES>14Ba#1H&(%P{RubhEf9thF1v;3|2E37{m+a>WB9qKYaKIWB|Dk+3?_u$fM4pIxzRBM<=@ zKrT=aA{!r*f9c}2|40DH1qwoBljDnaZa;AT+?5~Se*h7X0punp6v0h?{p#)gdyfw8 zKMq7d29OIkIW4*L+0z$)|NQ;*=`#=k89**j5U3aE?C{|9%CdHdcOWE?3lxNh5-`yG zy`c!m28sjY%kKQ$W*{Y8666>B5$I+Rh5{h-<3q7@P)cA-@^*J=6}_JI9LV7;@Q5sC zVBk9f!i-b3`J{n@>?NMQuIvvv#KpupJKB~`016%Uba4!kxSX7jkRsH^td=Im7G_q~ z!eGs=&MZ7ZUr9?%Pf=5qNhC3eq09U9iBl)FHMBInnJpCC~-b*UsHz)OzsZ$(u*7p1phcl2!D>w~t>x^KhYm#z&JbZTi%8YSk;L2@DrrZY}-F z+uQ4VR(I>6H!(bSY);I3{51K2xWgyA=?erH;zhLA9MTo=26{)e#5JNMC9x#cD!C{X zNHG{07@F%ESm+vAh8S8{nHX7_nrIssTNxM}s`;jeq9HdwB{QuOp}{!B(9+5XsMHdo zVT;S-M?ejdARB`7(@M${i&7cN%ggmL^RkPR6AM!H@{7`Ezq647Dq`?-^>bP0l+XkK D9+Hrj literal 0 HcmV?d00001 diff --git a/icon.iconset/icon_16x16@2x.png b/icon.iconset/icon_16x16@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..6447fe43638904e1d5043b44161835c872fb6053 GIT binary patch literal 1085 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdzmUKs7M+SzC{oH>NSwWJ?9znhg z3{`3j3=J&|48MRv4KElNN(~qoUL`OvSj}Ky5HFasE6@fg!ItFh?gFHN;HUHMdLYGF z;1OBOz`%C|gc+x5^GO2**-JcqUD+RUh>MAHcC;;>z`(%F?&;zf5^?zLwAD0Ds zDVl*LiD@Y zSF8Bvne0Rl9^cbzR-R%AYC64iv+a)7zrEEFyVmd=_E;JnHtFI-{w>?@F8S^!e{xB0 zmhqRZqNdM7T4t%;s88Tm_m@pgEEL?eZlaG`p$z-sg1~?o{%6}5I$MrjcyRmY+lI#$ zA=gd8{Xen-%;_;=x*%y0vYLsa-GkBaSRe*4&$!B$f3qEa;@<{W&uHs!NYm86VfKzCes+;;oDjT>{_IThA&whFi?8Cu=4>O6k@vYrIc zvls7qst5&3oz2>=aGu>E`*i4`hu`;KZ9DuvGj}H!$EQF0DtECOJ$-eox%%gikH)gO zb3@p!ZBX99df0$rgHHGDg}-^e@NZnbaNea$u3u~`rhQubBbveR?aKqZxTZ~+dI4zq z=~iu)@@tF&ulBVcfBfu6ry5uHr=Q7+P8x0hL-pG;DEs{0OK)5@bVg rep*R+Vo@qXd3m{BW?pu2a$-TMUVc&f>~}U&Kt&9mu6{1-oD!M<(2~ci literal 0 HcmV?d00001 diff --git a/icon.iconset/icon_256x256.png b/icon.iconset/icon_256x256.png new file mode 100644 index 0000000000000000000000000000000000000000..d12764ae1a5bf254dde19cc5ff878842e6cc85d4 GIT binary patch literal 6986 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 literal 0 HcmV?d00001 diff --git a/icon.iconset/icon_256x256@2x.png b/icon.iconset/icon_256x256@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9ec04491f691c35fa8da54debb8b7033f4cf9797 GIT binary patch literal 14902 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 literal 0 HcmV?d00001 diff --git a/icon.iconset/icon_32x32.png b/icon.iconset/icon_32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..6447fe43638904e1d5043b44161835c872fb6053 GIT binary patch literal 1085 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdzmUKs7M+SzC{oH>NSwWJ?9znhg z3{`3j3=J&|48MRv4KElNN(~qoUL`OvSj}Ky5HFasE6@fg!ItFh?gFHN;HUHMdLYGF z;1OBOz`%C|gc+x5^GO2**-JcqUD+RUh>MAHcC;;>z`(%F?&;zf5^?zLwAD0Ds zDVl*LiD@Y zSF8Bvne0Rl9^cbzR-R%AYC64iv+a)7zrEEFyVmd=_E;JnHtFI-{w>?@F8S^!e{xB0 zmhqRZqNdM7T4t%;s88Tm_m@pgEEL?eZlaG`p$z-sg1~?o{%6}5I$MrjcyRmY+lI#$ zA=gd8{Xen-%;_;=x*%y0vYLsa-GkBaSRe*4&$!B$f3qEa;@<{W&uHs!NYm86VfKzCes+;;oDjT>{_IThA&whFi?8Cu=4>O6k@vYrIc zvls7qst5&3oz2>=aGu>E`*i4`hu`;KZ9DuvGj}H!$EQF0DtECOJ$-eox%%gikH)gO zb3@p!ZBX99df0$rgHHGDg}-^e@NZnbaNea$u3u~`rhQubBbveR?aKqZxTZ~+dI4zq z=~iu)@@tF&ulBVcfBfu6ry5uHr=Q7+P8x0hL-pG;DEs{0OK)5@bVg rep*R+Vo@qXd3m{BW?pu2a$-TMUVc&f>~}U&Kt&9mu6{1-oD!M<(2~ci literal 0 HcmV?d00001 diff --git a/icon.iconset/icon_32x32@2x.png b/icon.iconset/icon_32x32@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..134f027cda56e5ed42832ebdba30d13bccef1342 GIT binary patch literal 1817 zcmZWoc{JPk7XR8~_f&~eYXw!rPHd@VB(?9`GpdOtv=U2&qES^21^|%6 zVw`*+M1MD!FyxyF3GX2gj6Umr7J#3Ic$v902yARMs*8myrOh zo(BN+4*(QMcb|FNKo`OnTwRF>(!wb{hZ-LluvirTnF;~g7V|63i)S`ZY0KGyJD`zHs|RJ zbygI}mc(Qr5vz1Vgk9>bThh6M;@M~=HMD>Ryb~6L77RJB3zO4Nc3i))Ue2C2v6J(f zExM12mY(7Dc`Q{QeH*h#>aDVUct$6;o&&KB-WcyRG6vkNTV z$uXw}w^4))Y?IUd!-To3?|Xg+*pl3FueQ#@-QA}YWoh5#7^JjV*T;Qo^Qc z<&L3&mieU0xQc`@;rXuK`-xARdm(^CFJoRI@i#59T_)oDYHt;F`Sw1&<623Lu}o6& zFFzQ0Adpv7RNvWRd2!}|+S(q;2~?)>MEm;08A#yefBaXIQpLTApvRlo*`2xcQ2WX_ z!Fo=?-sW{rm>|Co@7#+t&WdLh%ILwm5AK-i^WUhp^w`Yh?^}3G+O**Mu1t#05xAW$~6%B6JM)$qK=vfVVX{{ydmm-^j5eR395!VYOh({;rj=@9k|oLr!BN7bIQOID{oB2V zXNTxld+Y`KM|X|q^S-WSk(Lgb?UGYNIX-F{H`Ubz2V~&GHKS;FsWrky5bZNgVk_B- zZA(#Z8f6P(v5X^C;OAd|0jnrpn-=TE`@6F3wqvJm_U%8^}bu6a5aKg8QNsOQ2(LS=AJ%<>dnT_%%C0wGN8^XNx5{FrR|kR z;-eaTyV|Ja_lJ33^JP_P%01oOb|Wr_MbDGF{NmphN5ETxwv{_R*?e>1ew!NOH?J-l zDiBw!tQdG^Xx_2EcsaGlzJv7pY)PD9ILF_dIK$jA>%vDBMXzuQ;P&co+|ri>o;H0s z=99)7t$h1rJFa)8O<|*{W27c6dwi+jh##<5ZBKC$XvWLX83Z!tuUwSUp(_= z5~3YH&2QZCvZE9#uS?uvM7<8@!pFJ5 z*?&2v@1?ZJ?VoI!n%hhA!o}}?T~&f|xf)-p`Bvsd1_xsXU%X&?2J&TQW-edxiVa#J z@`Xu5o1tZWCQEnwnr`bqG4jSo0l!bxy%BB+kjwgu2LhB+D$Qce{Z}7`1vTm%MwTd3W0aYx z9?}GbL=I$d4gODXiHIjerTtG};%8)qGKQ#D{}psPE&c%s6u(#SB}OHaBN8J4nM^hy e#K$G!B1n-2#Kh=ow#^BM1hCE?PBe$}+5Z6oqAg$m literal 0 HcmV?d00001 diff --git a/icon.iconset/icon_512x512.png b/icon.iconset/icon_512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..9ec04491f691c35fa8da54debb8b7033f4cf9797 GIT binary patch literal 14902 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 literal 0 HcmV?d00001 diff --git a/icon.iconset/icon_512x512@2x.png b/icon.iconset/icon_512x512@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..99222ac16880a8834d6c39443d28ddab2c8c5fed GIT binary patch literal 25720 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 literal 0 HcmV?d00001 diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 00000000..10c1fe79 --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,94 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Package api provides HTTP API of the Bridge. +// +// API endpoints: +// * /focus, see focusHandler +package api + +import ( + "crypto/tls" + "fmt" + "net/http" + + "github.com/ProtonMail/proton-bridge/internal/bridge" + "github.com/ProtonMail/proton-bridge/internal/events" + "github.com/ProtonMail/proton-bridge/internal/preferences" + "github.com/ProtonMail/proton-bridge/pkg/config" + "github.com/ProtonMail/proton-bridge/pkg/listener" + "github.com/ProtonMail/proton-bridge/pkg/ports" +) + +var ( + log = config.GetLogEntry("api") //nolint[gochecknoglobals] +) + +type apiServer struct { + host string + pref *config.Preferences + tls *tls.Config + certPath string + keyPath string + eventListener listener.Listener +} + +// NewAPIServer returns prepared API server struct. +func NewAPIServer(pref *config.Preferences, tls *tls.Config, certPath, keyPath string, eventListener listener.Listener) *apiServer { //nolint[golint] + return &apiServer{ + host: bridge.Host, + pref: pref, + tls: tls, + certPath: certPath, + keyPath: keyPath, + eventListener: eventListener, + } +} + +// Starts the server. +func (api *apiServer) ListenAndServe() { + mux := http.NewServeMux() + mux.HandleFunc("/focus", wrapper(api, focusHandler)) + + addr := api.getAddress() + server := &http.Server{ + Addr: addr, + Handler: mux, + TLSConfig: api.tls, + TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)), + } + + log.Info("API listening at ", addr) + if err := server.ListenAndServeTLS(api.certPath, api.keyPath); err != nil { + api.eventListener.Emit(events.ErrorEvent, "API failed: "+err.Error()) + log.Error("API failed: ", err) + } + defer server.Close() //nolint[errcheck] +} + +func (api *apiServer) getAddress() string { + port := api.pref.GetInt(preferences.APIPortKey) + newPort := ports.FindFreePortFrom(port) + if newPort != port { + api.pref.SetInt(preferences.APIPortKey, newPort) + } + return getAPIAddress(api.host, newPort) +} + +func getAPIAddress(host string, port int) string { + return fmt.Sprintf("%s:%d", host, port) +} diff --git a/internal/api/ctx.go b/internal/api/ctx.go new file mode 100644 index 00000000..4c74ee59 --- /dev/null +++ b/internal/api/ctx.go @@ -0,0 +1,51 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package api + +import ( + "net/http" + + "github.com/ProtonMail/proton-bridge/pkg/listener" +) + +// httpHandler with Go's Response and Request. +type httpHandler func(http.ResponseWriter, *http.Request) + +// handler with our context. +type handler func(handlerContext) error + +type handlerContext struct { + req *http.Request + resp http.ResponseWriter + eventListener listener.Listener +} + +func wrapper(api *apiServer, callback handler) httpHandler { + return func(w http.ResponseWriter, req *http.Request) { + ctx := handlerContext{ + req: req, + resp: w, + eventListener: api.eventListener, + } + err := callback(ctx) + if err != nil { + log.Error("API callback of ", req.URL, " failed: ", err) + http.Error(w, err.Error(), 500) + } + } +} diff --git a/internal/api/focus.go b/internal/api/focus.go new file mode 100644 index 00000000..ac83023d --- /dev/null +++ b/internal/api/focus.go @@ -0,0 +1,55 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package api + +import ( + "crypto/tls" + "fmt" + "net/http" + + "github.com/ProtonMail/proton-bridge/internal/bridge" + "github.com/ProtonMail/proton-bridge/internal/events" +) + +// focusHandler should be called from other instances (attempt to start bridge +// for the second time) to get focus in the currently running instance. +func focusHandler(ctx handlerContext) error { + log.Info("Focus from other instance") + ctx.eventListener.Emit(events.SecondInstanceEvent, "") + fmt.Fprintf(ctx.resp, "OK") + return nil +} + +// CheckOtherInstanceAndFocus is helper for new instances to check if there is +// already a running instance and get it's focus. +func CheckOtherInstanceAndFocus(port int, tls *tls.Config) error { + transport := &http.Transport{TLSClientConfig: tls} + client := &http.Client{Transport: transport} + + addr := getAPIAddress(bridge.Host, port) + resp, err := client.Get("https://" + addr + "/focus") + if err != nil { + return err + } + defer resp.Body.Close() //nolint[errcheck] + + if resp.StatusCode != 200 { + log.Error("Focus error: ", resp.StatusCode) + } + return nil +} diff --git a/internal/bridge/bridge.go b/internal/bridge/bridge.go new file mode 100644 index 00000000..b55b48f3 --- /dev/null +++ b/internal/bridge/bridge.go @@ -0,0 +1,510 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Package bridge provides core business logic providing API over credentials store and PM API. +package bridge + +import ( + "errors" + "strconv" + "strings" + "sync" + "time" + + "github.com/ProtonMail/proton-bridge/internal/events" + m "github.com/ProtonMail/proton-bridge/internal/metrics" + "github.com/ProtonMail/proton-bridge/internal/preferences" + "github.com/ProtonMail/proton-bridge/internal/store" + "github.com/ProtonMail/proton-bridge/pkg/config" + "github.com/ProtonMail/proton-bridge/pkg/listener" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/hashicorp/go-multierror" + logrus "github.com/sirupsen/logrus" +) + +var ( + log = config.GetLogEntry("bridge") //nolint[gochecknoglobals] + isApplicationOutdated = false //nolint[gochecknoglobals] +) + +// Bridge is a struct handling users. +type Bridge struct { + config Configer + pref PreferenceProvider + panicHandler PanicHandler + events listener.Listener + version string + pmapiClientFactory PMAPIProviderFactory + credStorer CredentialsStorer + storeCache *store.Cache + + // users is a list of accounts that have been added to bridge. + // They are stored sorted in the credentials store in the order + // that they were added to bridge chronologically. + // People are used to that and so we preserve that ordering here. + users []*User + + // idleUpdates is a channel which the imap backend listens to and which it uses + // to send idle updates to the mail client (eg thunderbird). + // The user stores should send idle updates on this channel. + idleUpdates chan interface{} + + lock sync.RWMutex + + userAgentClientName string + userAgentClientVersion string + userAgentOS string +} + +func New( + config Configer, + pref PreferenceProvider, + panicHandler PanicHandler, + eventListener listener.Listener, + version string, + pmapiClientFactory PMAPIProviderFactory, + credStorer CredentialsStorer, +) *Bridge { + log.Trace("Creating new bridge") + + b := &Bridge{ + config: config, + pref: pref, + panicHandler: panicHandler, + events: eventListener, + version: version, + pmapiClientFactory: pmapiClientFactory, + credStorer: credStorer, + storeCache: store.NewCache(config.GetIMAPCachePath()), + idleUpdates: make(chan interface{}), + lock: sync.RWMutex{}, + } + + // Allow DoH before starting bridge if the user has previously set this setting. + // This allows us to start even if protonmail is blocked. + if pref.GetBool(preferences.AllowProxyKey) { + AllowDoH() + } + + go func() { + defer panicHandler.HandlePanic() + b.watchBridgeOutdated() + }() + + if b.credStorer == nil { + log.Error("Bridge has no credentials store") + } else if err := b.loadUsersFromCredentialsStore(); err != nil { + log.WithError(err).Error("Could not load all users from credentials store") + } + + if pref.GetBool(preferences.FirstStartKey) { + b.SendMetric(m.New(m.Setup, m.FirstStart, m.Label(version))) + } + + go b.heartbeat() + + return b +} + +// heartbeat sends a heartbeat signal once a day. +func (b *Bridge) heartbeat() { + for range time.NewTicker(1 * time.Hour).C { + next, err := strconv.ParseInt(b.pref.Get(preferences.NextHeartbeatKey), 10, 64) + if err != nil { + continue + } + nextTime := time.Unix(next, 0) + if time.Now().After(nextTime) { + b.SendMetric(m.New(m.Heartbeat, m.Daily, m.NoLabel)) + nextTime = nextTime.Add(24 * time.Hour) + b.pref.Set(preferences.NextHeartbeatKey, strconv.FormatInt(nextTime.Unix(), 10)) + } + } +} + +func (b *Bridge) loadUsersFromCredentialsStore() (err error) { + b.lock.Lock() + defer b.lock.Unlock() + + userIDs, err := b.credStorer.List() + if err != nil { + return + } + + for _, userID := range userIDs { + l := log.WithField("user", userID) + + apiClient := b.pmapiClientFactory(userID) + + user, newUserErr := newUser(b.panicHandler, userID, b.events, b.credStorer, apiClient, b.storeCache, b.config.GetDBDir()) + if newUserErr != nil { + l.WithField("user", userID).WithError(newUserErr).Warn("Could not load user, skipping") + continue + } + + b.users = append(b.users, user) + + if initUserErr := user.init(b.idleUpdates, apiClient); initUserErr != nil { + l.WithField("user", userID).WithError(initUserErr).Warn("Could not initialise user") + } + } + + return err +} + +func (b *Bridge) watchBridgeOutdated() { + ch := make(chan string) + b.events.Add(events.UpgradeApplicationEvent, ch) + for range ch { + isApplicationOutdated = true + b.closeAllConnections() + } +} + +func (b *Bridge) closeAllConnections() { + for _, user := range b.users { + user.closeAllConnections() + } +} + +// Login authenticates a user. +// The login flow: +// * Authenticate user: +// client, auth, err := bridge.Authenticate(username, password) +// +// * In case user `auth.HasTwoFactor()`, ask for it and fully authenticate the user. +// auth2FA, err := client.Auth2FA(twoFactorCode) +// +// * In case user `auth.HasMailboxPassword()`, ask for it, otherwise use `password` +// and then finish the login procedure. +// user, err := bridge.FinishLogin(client, auth, mailboxPassword) +func (b *Bridge) Login(username, password string) (loginClient PMAPIProvider, auth *pmapi.Auth, err error) { + log.WithField("username", username).Trace("Logging in to bridge") + + b.crashBandicoot(username) + + // We need to use "login" client because we need userID to properly + // assign access tokens into token manager. + loginClient = b.pmapiClientFactory("login") + + authInfo, err := loginClient.AuthInfo(username) + if err != nil { + log.WithField("username", username).WithError(err).Error("Could not get auth info for user") + return nil, nil, err + } + + if auth, err = loginClient.Auth(username, password, authInfo); err != nil { + log.WithField("username", username).WithError(err).Error("Could not get auth for user") + return loginClient, auth, err + } + + return loginClient, auth, nil +} + +// FinishLogin finishes the login procedure and adds the user into the credentials store. +// See `Login` for more details of the login flow. +func (b *Bridge) FinishLogin(loginClient PMAPIProvider, auth *pmapi.Auth, mbPassword string) (user *User, err error) { //nolint[funlen] + log.Trace("Finishing bridge login") + + defer func() { + if err == pmapi.ErrUpgradeApplication { + b.events.Emit(events.UpgradeApplicationEvent, "") + } + }() + + b.lock.Lock() + defer b.lock.Unlock() + + mbPassword, err = pmapi.HashMailboxPassword(mbPassword, auth.KeySalt) + if err != nil { + log.WithError(err).Error("Could not hash mailbox password") + if logoutErr := loginClient.Logout(); logoutErr != nil { + log.WithError(logoutErr).Error("Clean login session after hash password failed.") + } + return + } + + if _, err = loginClient.Unlock(mbPassword); err != nil { + log.WithError(err).Error("Could not decrypt keyring") + if logoutErr := loginClient.Logout(); logoutErr != nil { + log.WithError(logoutErr).Error("Clean login session after unlock failed.") + } + return + } + + apiUser, err := loginClient.CurrentUser() + if err != nil { + log.WithError(err).Error("Could not get login API user") + if logoutErr := loginClient.Logout(); logoutErr != nil { + log.WithError(logoutErr).Error("Clean login session after get current user failed.") + } + return + } + + user, hasUser := b.hasUser(apiUser.ID) + + // If the user exists and is logged in, we don't want to do anything. + if hasUser && user.IsConnected() { + err = errors.New("user is already logged in") + log.WithError(err).Warn("User is already logged in") + if logoutErr := loginClient.Logout(); logoutErr != nil { + log.WithError(logoutErr).Warn("Could not discard auth generated during second login") + } + return + } + + apiToken := auth.UID() + ":" + auth.RefreshToken + apiClient := b.pmapiClientFactory(apiUser.ID) + auth, err = apiClient.AuthRefresh(apiToken) + if err != nil { + log.WithError(err).Error("Could refresh token in new client") + if logoutErr := loginClient.Logout(); logoutErr != nil { + log.WithError(logoutErr).Warn("Could not discard auth generated after auth refresh") + } + return + } + + // We load the current user again because it should now have addresses loaded. + apiUser, err = apiClient.CurrentUser() + if err != nil { + log.WithError(err).Error("Could not get current API user") + if logoutErr := loginClient.Logout(); logoutErr != nil { + log.WithError(logoutErr).Error("Clean login session after get current user failed.") + } + return + } + + apiToken = auth.UID() + ":" + auth.RefreshToken + activeEmails := apiClient.Addresses().ActiveEmails() + if _, err = b.credStorer.Add(apiUser.ID, apiUser.Name, apiToken, mbPassword, activeEmails); err != nil { + log.WithError(err).Error("Could not add user to credentials store") + return + } + + // If it's a new user, generate the user object. + if !hasUser { + user, err = newUser(b.panicHandler, apiUser.ID, b.events, b.credStorer, apiClient, b.storeCache, b.config.GetDBDir()) + if err != nil { + log.WithField("user", apiUser.ID).WithError(err).Error("Could not create user") + return + } + } + + // Set up the user auth and store (which we do for both new and existing users). + if err = user.init(b.idleUpdates, apiClient); err != nil { + log.WithField("user", user.userID).WithError(err).Error("Could not initialise user") + return + } + + if !hasUser { + b.users = append(b.users, user) + b.SendMetric(m.New(m.Setup, m.NewUser, m.NoLabel)) + } + + b.events.Emit(events.UserRefreshEvent, apiUser.ID) + + return user, err +} + +// GetUsers returns all added users into keychain (even logged out users). +func (b *Bridge) GetUsers() []*User { + b.lock.RLock() + defer b.lock.RUnlock() + + return b.users +} + +// GetUser returns a user by `query` which is compared to users' ID, username +// or any attached e-mail address. +func (b *Bridge) GetUser(query string) (*User, error) { + b.crashBandicoot(query) + + b.lock.RLock() + defer b.lock.RUnlock() + + for _, user := range b.users { + if strings.EqualFold(user.ID(), query) || strings.EqualFold(user.Username(), query) { + return user, nil + } + for _, address := range user.GetAddresses() { + if strings.EqualFold(address, query) { + return user, nil + } + } + } + + return nil, errors.New("user " + query + " not found") +} + +// ClearData closes all connections (to release db files and so on) and clears all data. +func (b *Bridge) ClearData() error { + var result *multierror.Error + for _, user := range b.users { + if err := user.Logout(); err != nil { + result = multierror.Append(result, err) + } + if err := user.closeStore(); err != nil { + result = multierror.Append(result, err) + } + } + if err := b.config.ClearData(); err != nil { + result = multierror.Append(result, err) + } + return result.ErrorOrNil() +} + +// DeleteUser deletes user completely; it logs user out from the API, stops any +// active connection, deletes from credentials store and removes from the Bridge struct. +func (b *Bridge) DeleteUser(userID string, clearStore bool) error { + b.lock.Lock() + defer b.lock.Unlock() + + log := log.WithField("user", userID) + + for idx, user := range b.users { + if user.ID() == userID { + if err := user.Logout(); err != nil { + log.WithError(err).Error("Cannot logout user") + // We can try to continue to remove the user. + // Token will still be valid, but will expire eventually. + } + + if err := user.closeStore(); err != nil { + log.WithError(err).Error("Failed to close user store") + } + if clearStore { + // Clear cache after closing connections (done in logout). + if err := user.clearStore(); err != nil { + log.WithError(err).Error("Failed to clear user") + } + } + + if err := b.credStorer.Delete(userID); err != nil { + log.WithError(err).Error("Cannot remove user") + return err + } + b.users = append(b.users[:idx], b.users[idx+1:]...) + return nil + } + } + + return errors.New("user " + userID + " not found") +} + +// ReportBug reports a new bug from the user. +func (b *Bridge) ReportBug(osType, osVersion, description, accountName, address, emailClient string) error { + apiClient := b.pmapiClientFactory("bug_reporter") + title := "[Bridge] Bug" + err := apiClient.ReportBugWithEmailClient( + osType, + osVersion, + title, + description, + accountName, + address, + emailClient, + ) + if err != nil { + log.Error("Reporting bug failed: ", err) + return err + } + log.Info("Bug successfully reported") + return nil +} + +// SendMetric sends a metric. We don't want to return any errors, only log them. +func (b *Bridge) SendMetric(m m.Metric) { + apiClient := b.pmapiClientFactory("metric_reporter") + cat, act, lab := m.Get() + err := apiClient.SendSimpleMetric(string(cat), string(act), string(lab)) + if err != nil { + log.Error("Sending metric failed: ", err) + } + log.WithFields(logrus.Fields{ + "cat": cat, + "act": act, + "lab": lab, + }).Debug("Metric successfully sent") +} + +// GetCurrentClient returns currently connected client (e.g. Thunderbird). +func (b *Bridge) GetCurrentClient() string { + res := b.userAgentClientName + if b.userAgentClientVersion != "" { + res = res + " " + b.userAgentClientVersion + } + return res +} + +// SetCurrentClient updates client info (e.g. Thunderbird) and sets the user agent +// on pmapi. By default no client is used, IMAP has to detect it on first login. +func (b *Bridge) SetCurrentClient(clientName, clientVersion string) { + b.userAgentClientName = clientName + b.userAgentClientVersion = clientVersion + b.updateCurrentUserAgent() +} + +// SetCurrentOS updates OS and sets the user agent on pmapi. By default we use +// `runtime.GOOS`, but this can be overridden in case of better detection. +func (b *Bridge) SetCurrentOS(os string) { + b.userAgentOS = os + b.updateCurrentUserAgent() +} + +// GetIMAPUpdatesChannel sets the channel on which idle events should be sent. +func (b *Bridge) GetIMAPUpdatesChannel() chan interface{} { + if b.idleUpdates == nil { + log.Warn("Bridge updates channel is nil") + } + + return b.idleUpdates +} + +// AllowDoH instructs bridge to use DoH to access an API proxy if necessary. +// It also needs to work before bridge is initialised (because we may need to use the proxy at startup). +func AllowDoH() { + pmapi.GlobalAllowDoH() +} + +// DisallowDoH instructs bridge to not use DoH to access an API proxy if necessary. +// It also needs to work before bridge is initialised (because we may need to use the proxy at startup). +func DisallowDoH() { + pmapi.GlobalDisallowDoH() +} + +func (b *Bridge) updateCurrentUserAgent() { + UpdateCurrentUserAgent(b.version, b.userAgentOS, b.userAgentClientName, b.userAgentClientVersion) +} + +// hasUser returns whether the bridge currently has a user with ID `id`. +func (b *Bridge) hasUser(id string) (user *User, ok bool) { + for _, u := range b.users { + if u.ID() == id { + user, ok = u, true + return + } + } + + return +} + +// "Easter egg" for testing purposes. +func (b *Bridge) crashBandicoot(username string) { + if username == "crash@bandicoot" { + panic("Your wish is my command… I crash!") + } +} diff --git a/internal/bridge/bridge_login_test.go b/internal/bridge/bridge_login_test.go new file mode 100644 index 00000000..3b07c657 --- /dev/null +++ b/internal/bridge/bridge_login_test.go @@ -0,0 +1,233 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package bridge + +import ( + "errors" + "testing" + + "github.com/ProtonMail/proton-bridge/internal/bridge/credentials" + "github.com/ProtonMail/proton-bridge/internal/events" + "github.com/ProtonMail/proton-bridge/internal/metrics" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + gomock "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" +) + +func TestBridgeFinishLoginBadPassword(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + // Init bridge with no user from keychain. + m.credentialsStore.EXPECT().List().Return([]string{}, nil) + + // Set up mocks for FinishLogin. + err := errors.New("bad password") + m.pmapiClient.EXPECT().Unlock(testCredentials.MailboxPassword).Return(nil, err) + m.pmapiClient.EXPECT().Logout().Return(nil) + + checkBridgeFinishLogin(t, m, testAuth, testCredentials.MailboxPassword, "", err) +} + +func TestBridgeFinishLoginUpgradeApplication(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + // Init bridge with no user from keychain. + m.credentialsStore.EXPECT().List().Return([]string{}, nil) + + // Set up mocks for FinishLogin. + m.pmapiClient.EXPECT().Unlock(testCredentials.MailboxPassword).Return(nil, pmapi.ErrUpgradeApplication) + + m.eventListener.EXPECT().Emit(events.UpgradeApplicationEvent, "") + err := errors.New("Cannot logout when upgrade needed") + m.pmapiClient.EXPECT().Logout().Return(err) + + checkBridgeFinishLogin(t, m, testAuth, testCredentials.MailboxPassword, "", pmapi.ErrUpgradeApplication) +} + +func refreshWithToken(token string) *pmapi.Auth { + return &pmapi.Auth{ + RefreshToken: token, + KeySalt: "", // No salting in tests. + } +} + +func credentialsWithToken(token string) *credentials.Credentials { + tmp := &credentials.Credentials{} + *tmp = *testCredentials + tmp.APIToken = token + return tmp +} + +func TestBridgeFinishLoginNewUser(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + // Bridge finds no users in the keychain. + m.credentialsStore.EXPECT().List().Return([]string{}, nil) + + // Get user to be able to setup new client with proper userID. + m.pmapiClient.EXPECT().Unlock(testCredentials.MailboxPassword).Return(nil, nil) + m.pmapiClient.EXPECT().CurrentUser().Return(testPMAPIUser, nil) + + // Setup of new client. + m.pmapiClient.EXPECT().AuthRefresh(":tok").Return(refreshWithToken("afterLogin"), nil) + m.pmapiClient.EXPECT().CurrentUser().Return(testPMAPIUser, nil) + m.pmapiClient.EXPECT().Addresses().Return([]*pmapi.Address{testPMAPIAddress}) + + // Set up mocks for authorising the new user (in user.init). + m.credentialsStore.EXPECT().Add("user", "username", ":afterLogin", testCredentials.MailboxPassword, []string{testPMAPIAddress.Email}) + m.credentialsStore.EXPECT().Get("user").Return(credentialsWithToken(":afterLogin"), nil).Times(2) + m.pmapiClient.EXPECT().AuthRefresh(":afterLogin").Return(refreshWithToken("afterCredentials"), nil) + m.credentialsStore.EXPECT().Get("user").Return(credentialsWithToken("afterCredentials"), nil) + m.pmapiClient.EXPECT().Unlock(testCredentials.MailboxPassword).Return(nil, nil) + m.pmapiClient.EXPECT().UnlockAddresses([]byte(testCredentials.MailboxPassword)).Return(nil) + + m.credentialsStore.EXPECT().UpdateToken("user", ":afterCredentials").Return(nil) + + // Set up mocks for creating the user's store (in store.New). + m.pmapiClient.EXPECT().ListLabels().Return([]*pmapi.Label{}, nil) + m.pmapiClient.EXPECT().Addresses().Return([]*pmapi.Address{testPMAPIAddress}) + m.pmapiClient.EXPECT().CountMessages("").Return([]*pmapi.MessagesCount{}, nil) + + // Emit event for new user and send metrics. + m.eventListener.EXPECT().Emit(events.UserRefreshEvent, "user") + m.pmapiClient.EXPECT().SendSimpleMetric(string(metrics.Setup), string(metrics.NewUser), string(metrics.NoLabel)) + + // Set up mocks for starting the store's event loop (in store.New). + // The event loop runs in another goroutine so this might happen at any time. + m.pmapiClient.EXPECT().GetEvent("").Return(testPMAPIEvent, nil) + m.pmapiClient.EXPECT().GetEvent(testPMAPIEvent.EventID).Return(testPMAPIEvent, nil) + + // Set up mocks for performing the initial store sync. + m.pmapiClient.EXPECT().ListMessages(gomock.Any()).Return([]*pmapi.Message{}, 0, nil) + + checkBridgeFinishLogin(t, m, testAuth, testCredentials.MailboxPassword, "user", nil) +} + +func TestBridgeFinishLoginExistingUser(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + loggedOutCreds := *testCredentials + loggedOutCreds.APIToken = "" + loggedOutCreds.MailboxPassword = "" + + // Bridge finds one logged out user in the keychain. + m.credentialsStore.EXPECT().List().Return([]string{"user"}, nil) + // New user + m.credentialsStore.EXPECT().Get("user").Return(&loggedOutCreds, nil) + // Init user + m.credentialsStore.EXPECT().Get("user").Return(&loggedOutCreds, nil) + m.pmapiClient.EXPECT().ListLabels().Return(nil, pmapi.ErrInvalidToken) + m.pmapiClient.EXPECT().Addresses().Return(nil) + + // Get user to be able to setup new client with proper userID. + m.pmapiClient.EXPECT().Unlock(testCredentials.MailboxPassword).Return(nil, nil) + m.pmapiClient.EXPECT().CurrentUser().Return(testPMAPIUser, nil) + + // Setup of new client. + m.pmapiClient.EXPECT().AuthRefresh(":tok").Return(refreshWithToken("afterLogin"), nil) + m.pmapiClient.EXPECT().CurrentUser().Return(testPMAPIUser, nil) + m.pmapiClient.EXPECT().Addresses().Return([]*pmapi.Address{testPMAPIAddress}) + + // Set up mocks for authorising the new user (in user.init). + m.credentialsStore.EXPECT().Add("user", "username", ":afterLogin", testCredentials.MailboxPassword, []string{testPMAPIAddress.Email}) + m.credentialsStore.EXPECT().Get("user").Return(credentialsWithToken(":afterLogin"), nil) + m.pmapiClient.EXPECT().AuthRefresh(":afterLogin").Return(refreshWithToken("afterCredentials"), nil) + m.credentialsStore.EXPECT().Get("user").Return(credentialsWithToken("afterCredentials"), nil) + m.pmapiClient.EXPECT().Unlock(testCredentials.MailboxPassword).Return(nil, nil) + m.pmapiClient.EXPECT().UnlockAddresses([]byte(testCredentials.MailboxPassword)).Return(nil) + + m.credentialsStore.EXPECT().UpdateToken("user", ":afterCredentials").Return(nil) + + // Set up mocks for creating the user's store (in store.New). + m.pmapiClient.EXPECT().ListLabels().Return([]*pmapi.Label{}, nil) + m.pmapiClient.EXPECT().Addresses().Return([]*pmapi.Address{testPMAPIAddress}) + m.pmapiClient.EXPECT().CountMessages("").Return([]*pmapi.MessagesCount{}, nil) + + // Reload account list in GUI. + m.eventListener.EXPECT().Emit(events.UserRefreshEvent, "user") + + // Set up mocks for starting the store's event loop (in store.New) + // The event loop runs in another goroutine so this might happen at any time. + m.pmapiClient.EXPECT().GetEvent("").Return(testPMAPIEvent, nil) + m.pmapiClient.EXPECT().GetEvent(testPMAPIEvent.EventID).Return(testPMAPIEvent, nil) + + // Set up mocks for performing the initial store sync. + m.pmapiClient.EXPECT().ListMessages(gomock.Any()).Return([]*pmapi.Message{}, 0, nil) + + checkBridgeFinishLogin(t, m, testAuth, testCredentials.MailboxPassword, "user", nil) +} + +func TestBridgeDoubleLogin(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + // Firstly, start bridge with existing user... + + m.credentialsStore.EXPECT().List().Return([]string{"user"}, nil) + m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil) + + m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil) + m.pmapiClient.EXPECT().AuthRefresh("token").Return(testAuthRefresh, nil) + m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil) + m.credentialsStore.EXPECT().UpdateToken("user", ":reftok").Return(nil) + m.pmapiClient.EXPECT().Unlock(testCredentials.MailboxPassword).Return(nil, nil) + m.pmapiClient.EXPECT().UnlockAddresses([]byte(testCredentials.MailboxPassword)).Return(nil) + m.pmapiClient.EXPECT().ListLabels().Return([]*pmapi.Label{}, nil) + m.pmapiClient.EXPECT().CountMessages("").Return([]*pmapi.MessagesCount{}, nil) + m.pmapiClient.EXPECT().Addresses().Return([]*pmapi.Address{testPMAPIAddress}) + + m.pmapiClient.EXPECT().GetEvent("").Return(testPMAPIEvent, nil) + m.pmapiClient.EXPECT().ListMessages(gomock.Any()).Return([]*pmapi.Message{}, 0, nil) + m.pmapiClient.EXPECT().GetEvent(testPMAPIEvent.EventID).Return(testPMAPIEvent, nil) + + bridge := testNewBridge(t, m) + defer cleanUpBridgeUserData(bridge) + + // Then, try to log in again... + + m.pmapiClient.EXPECT().Unlock(testCredentials.MailboxPassword).Return(nil, nil) + m.pmapiClient.EXPECT().CurrentUser().Return(testPMAPIUser, nil) + m.pmapiClient.EXPECT().Logout() + + _, err := bridge.FinishLogin(m.pmapiClient, testAuth, testCredentials.MailboxPassword) + assert.Equal(t, "user is already logged in", err.Error()) +} + +func checkBridgeFinishLogin(t *testing.T, m mocks, auth *pmapi.Auth, mailboxPassword string, expectedUserID string, expectedErr error) { + bridge := testNewBridge(t, m) + defer cleanUpBridgeUserData(bridge) + + user, err := bridge.FinishLogin(m.pmapiClient, auth, mailboxPassword) + + waitForEvents() + + assert.Equal(t, expectedErr, err) + + if expectedUserID != "" { + assert.Equal(t, expectedUserID, user.ID()) + assert.Equal(t, 1, len(bridge.users)) + assert.Equal(t, expectedUserID, bridge.users[0].ID()) + } else { + assert.Equal(t, (*User)(nil), user) + assert.Equal(t, 0, len(bridge.users)) + } +} diff --git a/internal/bridge/bridge_new_test.go b/internal/bridge/bridge_new_test.go new file mode 100644 index 00000000..b4c02f89 --- /dev/null +++ b/internal/bridge/bridge_new_test.go @@ -0,0 +1,162 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package bridge + +import ( + "errors" + "testing" + + credentials "github.com/ProtonMail/proton-bridge/internal/bridge/credentials" + "github.com/ProtonMail/proton-bridge/internal/events" + "github.com/ProtonMail/proton-bridge/internal/metrics" + "github.com/ProtonMail/proton-bridge/internal/preferences" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + gomock "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" +) + +func TestNewBridgeNoKeychain(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + m.credentialsStore.EXPECT().List().Return([]string{}, errors.New("no keychain")) + + checkBridgeNew(t, m, []*credentials.Credentials{}) +} + +func TestNewBridgeWithoutUsersInCredentialsStore(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + m.credentialsStore.EXPECT().List().Return([]string{}, nil) + + checkBridgeNew(t, m, []*credentials.Credentials{}) +} + +func TestNewBridgeWithDisconnectedUser(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + m.credentialsStore.EXPECT().List().Return([]string{"user"}, nil) + m.credentialsStore.EXPECT().Get("user").Return(testCredentialsDisconnected, nil).Times(2) + m.pmapiClient.EXPECT().ListLabels().Return(nil, errors.New("ErrUnauthorized")) + m.pmapiClient.EXPECT().Addresses().Return(nil) + + checkBridgeNew(t, m, []*credentials.Credentials{testCredentialsDisconnected}) +} + +func TestNewBridgeWithConnectedUserWithBadToken(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + m.credentialsStore.EXPECT().List().Return([]string{"user"}, nil) + m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil).Times(2) + + m.credentialsStore.EXPECT().Logout("user").Return(nil) + m.pmapiClient.EXPECT().AuthRefresh("token").Return(nil, errors.New("bad token")) + + m.eventListener.EXPECT().Emit(events.LogoutEvent, "user") + m.eventListener.EXPECT().Emit(events.UserRefreshEvent, "user") + m.pmapiClient.EXPECT().Logout().Return(nil) + m.pmapiClient.EXPECT().SetAuths(nil) + m.credentialsStore.EXPECT().Logout("user").Return(nil) + m.credentialsStore.EXPECT().Get("user").Return(testCredentialsDisconnected, nil) + m.eventListener.EXPECT().Emit(events.CloseConnectionEvent, "user@pm.me") + + checkBridgeNew(t, m, []*credentials.Credentials{testCredentialsDisconnected}) +} + +func TestNewBridgeWithConnectedUser(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + m.pmapiClient.EXPECT().AuthRefresh("token").Return(testAuthRefresh, nil) + m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil) + m.pmapiClient.EXPECT().Unlock(testCredentials.MailboxPassword).Return(nil, nil) + m.pmapiClient.EXPECT().UnlockAddresses([]byte(testCredentials.MailboxPassword)).Return(nil) + + // Set up mocks for store initialisation for the authorized user. + m.pmapiClient.EXPECT().ListLabels().Return([]*pmapi.Label{}, nil) + m.pmapiClient.EXPECT().Addresses().Return([]*pmapi.Address{testPMAPIAddress}) + m.pmapiClient.EXPECT().CountMessages("").Return([]*pmapi.MessagesCount{}, nil) + m.pmapiClient.EXPECT().GetEvent("").Return(testPMAPIEvent, nil) + m.pmapiClient.EXPECT().ListMessages(gomock.Any()).Return([]*pmapi.Message{}, 0, nil).AnyTimes() + m.pmapiClient.EXPECT().GetEvent(testPMAPIEvent.EventID).Return(testPMAPIEvent, nil) + + m.credentialsStore.EXPECT().List().Return([]string{"user"}, nil) + m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil).Times(2) + m.credentialsStore.EXPECT().UpdateToken("user", ":reftok").Return(nil) + + checkBridgeNew(t, m, []*credentials.Credentials{testCredentials}) +} + +// Tests two users with different states and checks also the order from +// credentials store is kept also in array of Bridge users. +func TestNewBridgeWithUsers(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + m.pmapiClient.EXPECT().AuthRefresh("token").Return(testAuthRefresh, nil) + m.pmapiClient.EXPECT().Unlock(testCredentials.MailboxPassword).Return(nil, nil) + m.pmapiClient.EXPECT().UnlockAddresses([]byte(testCredentials.MailboxPassword)).Return(nil) + + m.credentialsStore.EXPECT().List().Return([]string{"user", "user"}, nil) + m.credentialsStore.EXPECT().Get("user").Return(testCredentialsDisconnected, nil).Times(2) + m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil).Times(2) + m.credentialsStore.EXPECT().UpdateToken("user", ":reftok").Return(nil) + + // Set up mocks for store initialisation for the unauth user. + m.pmapiClient.EXPECT().ListLabels().Return(nil, errors.New("ErrUnauthorized")) + m.pmapiClient.EXPECT().Addresses().Return(nil) + + // Set up mocks for store initialisation for the authorized user. + m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil) + m.pmapiClient.EXPECT().ListLabels().Return([]*pmapi.Label{}, nil) + m.pmapiClient.EXPECT().Addresses().Return([]*pmapi.Address{testPMAPIAddress}) + m.pmapiClient.EXPECT().CountMessages("").Return([]*pmapi.MessagesCount{}, nil) + m.pmapiClient.EXPECT().GetEvent("").Return(testPMAPIEvent, nil) + m.pmapiClient.EXPECT().ListMessages(gomock.Any()).Return([]*pmapi.Message{}, 0, nil).AnyTimes() + m.pmapiClient.EXPECT().GetEvent(testPMAPIEvent.EventID).Return(testPMAPIEvent, nil) + + checkBridgeNew(t, m, []*credentials.Credentials{testCredentialsDisconnected, testCredentials}) +} + +func TestNewBridgeFirstStart(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + m.prefProvider.EXPECT().GetBool(preferences.FirstStartKey).Return(true) + m.credentialsStore.EXPECT().List().Return([]string{}, nil) + m.pmapiClient.EXPECT().SendSimpleMetric(string(metrics.Setup), string(metrics.FirstStart), gomock.Any()) + + testNewBridge(t, m) +} + +func checkBridgeNew(t *testing.T, m mocks, expectedCredentials []*credentials.Credentials) { + bridge := testNewBridge(t, m) + defer cleanUpBridgeUserData(bridge) + + assert.Equal(m.t, len(expectedCredentials), len(bridge.GetUsers())) + + credentials := []*credentials.Credentials{} + for _, user := range bridge.users { + credentials = append(credentials, user.creds) + } + + assert.Equal(m.t, expectedCredentials, credentials) +} diff --git a/internal/bridge/bridge_test.go b/internal/bridge/bridge_test.go new file mode 100644 index 00000000..d636094c --- /dev/null +++ b/internal/bridge/bridge_test.go @@ -0,0 +1,256 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package bridge + +import ( + "io/ioutil" + "os" + "testing" + "time" + + "github.com/ProtonMail/proton-bridge/internal/bridge/credentials" + bridgemocks "github.com/ProtonMail/proton-bridge/internal/bridge/mocks" + "github.com/ProtonMail/proton-bridge/internal/events" + "github.com/ProtonMail/proton-bridge/internal/metrics" + "github.com/ProtonMail/proton-bridge/internal/preferences" + "github.com/ProtonMail/proton-bridge/internal/store" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + gomock "github.com/golang/mock/gomock" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/require" +) + +func TestMain(m *testing.M) { + if os.Getenv("VERBOSITY") == "trace" { + logrus.SetLevel(logrus.TraceLevel) + } + os.Exit(m.Run()) +} + +var ( + testAuth = &pmapi.Auth{ //nolint[gochecknoglobals] + RefreshToken: "tok", + KeySalt: "", // No salting in tests. + } + testAuthRefresh = &pmapi.Auth{ //nolint[gochecknoglobals] + RefreshToken: "reftok", + KeySalt: "", // No salting in tests. + } + + testCredentials = &credentials.Credentials{ //nolint[gochecknoglobals] + UserID: "user", + Name: "username", + Emails: "user@pm.me", + APIToken: "token", + MailboxPassword: "pass", + BridgePassword: "0123456789abcdef", + Version: "v1", + Timestamp: 123456789, + IsHidden: false, + IsCombinedAddressMode: true, + } + testCredentialsSplit = &credentials.Credentials{ //nolint[gochecknoglobals] + UserID: "users", + Name: "usersname", + Emails: "users@pm.me;anotheruser@pm.me;alsouser@pm.me", + APIToken: "token", + MailboxPassword: "pass", + BridgePassword: "0123456789abcdef", + Version: "v1", + Timestamp: 123456789, + IsHidden: false, + IsCombinedAddressMode: false, + } + testCredentialsDisconnected = &credentials.Credentials{ //nolint[gochecknoglobals] + UserID: "user", + Name: "username", + Emails: "user@pm.me", + APIToken: "", + MailboxPassword: "", + BridgePassword: "0123456789abcdef", + Version: "v1", + Timestamp: 123456789, + IsHidden: false, + IsCombinedAddressMode: true, + } + + testPMAPIUser = &pmapi.User{ //nolint[gochecknoglobals] + ID: "user", + Name: "username", + } + + testPMAPIAddress = &pmapi.Address{ //nolint[gochecknoglobals] + ID: "testAddressID", + Type: pmapi.OriginalAddress, + Email: "user@pm.me", + Receive: pmapi.CanReceive, + } + + testPMAPIAddresses = []*pmapi.Address{ //nolint[gochecknoglobals] + {ID: "usersAddress1ID", Email: "users@pm.me", Receive: pmapi.CanReceive, Type: pmapi.OriginalAddress}, + {ID: "usersAddress2ID", Email: "anotheruser@pm.me", Receive: pmapi.CanReceive, Type: pmapi.AliasAddress}, + {ID: "usersAddress3ID", Email: "alsouser@pm.me", Receive: pmapi.CanReceive, Type: pmapi.AliasAddress}, + } + + testPMAPIEvent = &pmapi.Event{ // nolint[gochecknoglobals] + EventID: "ACXDmTaBub14w==", + } +) + +func waitForEvents() { + // Wait for goroutine to add listener. + // E.g. calling login to invoke firstsync event. Functions can end sooner than + // goroutines call the listener mock. We need to wait a little bit before the end of + // the test to capture all event calls. This allows us to detect whether there were + // missing calls, or perhaps whether something was called too many times. + time.Sleep(100 * time.Millisecond) +} + +type mocks struct { + t *testing.T + + ctrl *gomock.Controller + config *bridgemocks.MockConfiger + PanicHandler *bridgemocks.MockPanicHandler + prefProvider *bridgemocks.MockPreferenceProvider + pmapiClient *bridgemocks.MockPMAPIProvider + credentialsStore *bridgemocks.MockCredentialsStorer + eventListener *MockListener + + storeCache *store.Cache +} + +func initMocks(t *testing.T) mocks { + mockCtrl := gomock.NewController(t) + + cacheFile, err := ioutil.TempFile("", "bridge-store-cache-*.db") + require.NoError(t, err, "could not get temporary file for store cache") + + m := mocks{ + t: t, + + ctrl: mockCtrl, + config: bridgemocks.NewMockConfiger(mockCtrl), + PanicHandler: bridgemocks.NewMockPanicHandler(mockCtrl), + pmapiClient: bridgemocks.NewMockPMAPIProvider(mockCtrl), + prefProvider: bridgemocks.NewMockPreferenceProvider(mockCtrl), + credentialsStore: bridgemocks.NewMockCredentialsStorer(mockCtrl), + eventListener: NewMockListener(mockCtrl), + + storeCache: store.NewCache(cacheFile.Name()), + } + + // Ignore heartbeat calls because they always happen. + m.pmapiClient.EXPECT().SendSimpleMetric(string(metrics.Heartbeat), gomock.Any(), gomock.Any()).AnyTimes() + m.prefProvider.EXPECT().Get(preferences.NextHeartbeatKey).AnyTimes() + m.prefProvider.EXPECT().Set(preferences.NextHeartbeatKey, gomock.Any()).AnyTimes() + + // Called during clean-up. + m.PanicHandler.EXPECT().HandlePanic().AnyTimes() + + return m +} + +func testNewBridgeWithUsers(t *testing.T, m mocks) *Bridge { + // Init for user. + m.pmapiClient.EXPECT().AuthRefresh("token").Return(testAuthRefresh, nil) + m.pmapiClient.EXPECT().Unlock("pass").Return(nil, nil) + m.pmapiClient.EXPECT().UnlockAddresses([]byte("pass")).Return(nil) + m.pmapiClient.EXPECT().ListLabels().Return([]*pmapi.Label{}, nil) + m.pmapiClient.EXPECT().Addresses().Return([]*pmapi.Address{testPMAPIAddress}) + m.pmapiClient.EXPECT().CountMessages("").Return([]*pmapi.MessagesCount{}, nil) + m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil).Times(2) + m.credentialsStore.EXPECT().UpdateToken("user", ":reftok").Return(nil) + m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil) + m.pmapiClient.EXPECT().GetEvent("").Return(testPMAPIEvent, nil) + m.pmapiClient.EXPECT().ListMessages(gomock.Any()).Return([]*pmapi.Message{}, 0, nil) + m.pmapiClient.EXPECT().GetEvent(testPMAPIEvent.EventID).Return(testPMAPIEvent, nil) + + // Init for users. + m.pmapiClient.EXPECT().AuthRefresh("token").Return(testAuthRefresh, nil) + m.pmapiClient.EXPECT().Unlock("pass").Return(nil, nil) + m.pmapiClient.EXPECT().UnlockAddresses([]byte("pass")).Return(nil) + m.pmapiClient.EXPECT().ListLabels().Return([]*pmapi.Label{}, nil) + m.pmapiClient.EXPECT().Addresses().Return(testPMAPIAddresses) + m.pmapiClient.EXPECT().CountMessages("").Return([]*pmapi.MessagesCount{}, nil) + m.credentialsStore.EXPECT().Get("users").Return(testCredentialsSplit, nil).Times(2) + m.credentialsStore.EXPECT().UpdateToken("users", ":reftok").Return(nil) + m.credentialsStore.EXPECT().Get("users").Return(testCredentialsSplit, nil) + m.pmapiClient.EXPECT().GetEvent("").Return(testPMAPIEvent, nil) + m.pmapiClient.EXPECT().ListMessages(gomock.Any()).Return([]*pmapi.Message{}, 0, nil) + m.pmapiClient.EXPECT().GetEvent(testPMAPIEvent.EventID).Return(testPMAPIEvent, nil) + + m.credentialsStore.EXPECT().List().Return([]string{"user", "users"}, nil) + + return testNewBridge(t, m) +} + +func testNewBridge(t *testing.T, m mocks) *Bridge { + cacheFile, err := ioutil.TempFile("", "bridge-store-cache-*.db") + require.NoError(t, err, "could not get temporary file for store cache") + + m.prefProvider.EXPECT().GetBool(preferences.FirstStartKey).Return(false).AnyTimes() + m.prefProvider.EXPECT().GetBool(preferences.AllowProxyKey).Return(false).AnyTimes() + m.config.EXPECT().GetDBDir().Return("/tmp").AnyTimes() + m.config.EXPECT().GetIMAPCachePath().Return(cacheFile.Name()).AnyTimes() + m.pmapiClient.EXPECT().SetAuths(gomock.Any()).AnyTimes() + m.eventListener.EXPECT().Add(events.UpgradeApplicationEvent, gomock.Any()) + pmapiClientFactory := func(userID string) PMAPIProvider { + log.WithField("userID", userID).Info("Creating new pmclient") + return m.pmapiClient + } + + bridge := New(m.config, m.prefProvider, m.PanicHandler, m.eventListener, "ver", pmapiClientFactory, m.credentialsStore) + + waitForEvents() + + return bridge +} + +func cleanUpBridgeUserData(b *Bridge) { + for _, user := range b.users { + _ = user.clearStore() + } +} + +func TestClearData(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + bridge := testNewBridgeWithUsers(t, m) + defer cleanUpBridgeUserData(bridge) + + m.eventListener.EXPECT().Emit(events.CloseConnectionEvent, "user@pm.me") + m.eventListener.EXPECT().Emit(events.CloseConnectionEvent, "users@pm.me") + m.eventListener.EXPECT().Emit(events.CloseConnectionEvent, "anotheruser@pm.me") + m.eventListener.EXPECT().Emit(events.CloseConnectionEvent, "alsouser@pm.me") + + m.pmapiClient.EXPECT().Logout() + m.credentialsStore.EXPECT().Logout("user").Return(nil) + m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil) + + m.pmapiClient.EXPECT().Logout() + m.credentialsStore.EXPECT().Logout("users").Return(nil) + m.credentialsStore.EXPECT().Get("users").Return(testCredentialsSplit, nil) + + m.config.EXPECT().ClearData().Return(nil) + + require.NoError(t, bridge.ClearData()) + + waitForEvents() +} diff --git a/internal/bridge/bridge_users_test.go b/internal/bridge/bridge_users_test.go new file mode 100644 index 00000000..a3cf48a9 --- /dev/null +++ b/internal/bridge/bridge_users_test.go @@ -0,0 +1,121 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package bridge + +import ( + "errors" + "testing" + + "github.com/ProtonMail/proton-bridge/internal/events" + "github.com/stretchr/testify/assert" +) + +func TestGetNoUser(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + checkBridgeGetUser(t, m, "nouser", -1, "user nouser not found") +} + +func TestGetUserByID(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + checkBridgeGetUser(t, m, "user", 0, "") + checkBridgeGetUser(t, m, "users", 1, "") +} + +func TestGetUserByName(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + checkBridgeGetUser(t, m, "username", 0, "") + checkBridgeGetUser(t, m, "usersname", 1, "") +} + +func TestGetUserByEmail(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + checkBridgeGetUser(t, m, "user@pm.me", 0, "") + checkBridgeGetUser(t, m, "users@pm.me", 1, "") + checkBridgeGetUser(t, m, "anotheruser@pm.me", 1, "") + checkBridgeGetUser(t, m, "alsouser@pm.me", 1, "") +} + +func TestDeleteUser(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + bridge := testNewBridgeWithUsers(t, m) + defer cleanUpBridgeUserData(bridge) + + m.pmapiClient.EXPECT().Logout().Return(nil) + + m.credentialsStore.EXPECT().Logout("user").Return(nil) + m.credentialsStore.EXPECT().Delete("user").Return(nil) + m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil) + + m.eventListener.EXPECT().Emit(events.CloseConnectionEvent, "user@pm.me") + + err := bridge.DeleteUser("user", true) + assert.NoError(t, err) + assert.Equal(t, 1, len(bridge.users)) +} + +// Even when logout fails, delete is done. +func TestDeleteUserWithFailingLogout(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + bridge := testNewBridgeWithUsers(t, m) + defer cleanUpBridgeUserData(bridge) + + m.pmapiClient.EXPECT().Logout().Return(nil) + + m.credentialsStore.EXPECT().Logout("user").Return(errors.New("logout failed")) + m.credentialsStore.EXPECT().Delete("user").Return(nil).Times(2) + m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil) + + m.eventListener.EXPECT().Emit(events.CloseConnectionEvent, "user@pm.me") + + err := bridge.DeleteUser("user", true) + assert.NoError(t, err) + assert.Equal(t, 1, len(bridge.users)) +} + +func checkBridgeGetUser(t *testing.T, m mocks, query string, index int, expectedError string) { + bridge := testNewBridgeWithUsers(t, m) + defer cleanUpBridgeUserData(bridge) + + user, err := bridge.GetUser(query) + waitForEvents() + + if expectedError != "" { + assert.Equal(m.t, expectedError, err.Error()) + } else { + assert.NoError(m.t, err) + } + + var expectedUser *User + if index >= 0 { + expectedUser = bridge.users[index] + } + + assert.Equal(m.t, expectedUser, user) +} diff --git a/internal/bridge/constants.go b/internal/bridge/constants.go new file mode 100644 index 00000000..fb4edd23 --- /dev/null +++ b/internal/bridge/constants.go @@ -0,0 +1,23 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package bridge + +// Host settings. +const ( + Host = "127.0.0.1" +) diff --git a/internal/bridge/credentials/credentials.go b/internal/bridge/credentials/credentials.go new file mode 100644 index 00000000..802d4526 --- /dev/null +++ b/internal/bridge/credentials/credentials.go @@ -0,0 +1,137 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Package credentials implements our struct stored in keychain. +// Store struct is kind of like a database client. +// Credentials struct is kind of like one record from the database. +package credentials + +import ( + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strings" + + "github.com/ProtonMail/proton-bridge/pkg/config" + "github.com/sirupsen/logrus" +) + +const sep = "\x00" + +var ( + log = config.GetLogEntry("bridge") //nolint[gochecknoglobals] + + ErrWrongFormat = errors.New("backend/creds: malformed password") +) + +type Credentials struct { + UserID, // Do not marshal; used as a key. + Name, + Emails, + APIToken, + MailboxPassword, + BridgePassword, + Version string + Timestamp int64 + IsHidden, // Deprecated. + IsCombinedAddressMode bool +} + +func (s *Credentials) Marshal() string { + items := []string{ + s.Name, // 0 + s.Emails, // 1 + s.APIToken, // 2 + s.MailboxPassword, // 3 + s.BridgePassword, // 4 + s.Version, // 5 + "", // 6 + "", // 7 + "", // 8 + } + + items[6] = fmt.Sprint(s.Timestamp) + + if s.IsHidden { + items[7] = "1" + } + + if s.IsCombinedAddressMode { + items[8] = "1" + } + + str := strings.Join(items, sep) + return base64.StdEncoding.EncodeToString([]byte(str)) +} + +func (s *Credentials) Unmarshal(secret string) error { + b, err := base64.StdEncoding.DecodeString(secret) + if err != nil { + return err + } + items := strings.Split(string(b), sep) + + if len(items) != 9 { + return ErrWrongFormat + } + + s.Name = items[0] + s.Emails = items[1] + s.APIToken = items[2] + s.MailboxPassword = items[3] + s.BridgePassword = items[4] + s.Version = items[5] + if _, err = fmt.Sscan(items[6], &s.Timestamp); err != nil { + s.Timestamp = 0 + } + if s.IsHidden = false; items[7] == "1" { + s.IsHidden = true + } + if s.IsCombinedAddressMode = false; items[8] == "1" { + s.IsCombinedAddressMode = true + } + return nil +} + +func (s *Credentials) SetEmailList(list []string) { + s.Emails = strings.Join(list, ";") +} + +func (s *Credentials) EmailList() []string { + return strings.Split(s.Emails, ";") +} + +func (s *Credentials) CheckPassword(password string) error { + if subtle.ConstantTimeCompare([]byte(s.BridgePassword), []byte(password)) != 1 { + log.WithFields(logrus.Fields{ + "userID": s.UserID, + }).Debug("Incorrect bridge password") + + return fmt.Errorf("backend/credentials: incorrect password") + } + return nil +} + +func (s *Credentials) Logout() { + s.APIToken = "" + s.MailboxPassword = "" +} + +func (s *Credentials) IsConnected() bool { + return s.APIToken != "" && s.MailboxPassword != "" +} diff --git a/internal/bridge/credentials/crypto.go b/internal/bridge/credentials/crypto.go new file mode 100644 index 00000000..ea41a5a5 --- /dev/null +++ b/internal/bridge/credentials/crypto.go @@ -0,0 +1,39 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package credentials + +import ( + "crypto/rand" + "encoding/base64" + "io" +) + +const keySize = 16 + +// generateKey generates a new random key. +func generateKey() []byte { + key := make([]byte, keySize) + if _, err := io.ReadFull(rand.Reader, key); err != nil { + panic(err) + } + return key +} + +func generatePassword() string { + return base64.RawURLEncoding.EncodeToString(generateKey()) +} diff --git a/internal/bridge/credentials/store.go b/internal/bridge/credentials/store.go new file mode 100644 index 00000000..f42d3ce3 --- /dev/null +++ b/internal/bridge/credentials/store.go @@ -0,0 +1,316 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package credentials + +import ( + "errors" + "fmt" + "sort" + "sync" + "time" + + "github.com/ProtonMail/proton-bridge/pkg/keychain" + "github.com/sirupsen/logrus" +) + +var storeLocker = sync.RWMutex{} //nolint[gochecknoglobals] + +// Store is an encrypted credentials store. +type Store struct { + secrets *keychain.Access +} + +// NewStore creates a new encrypted credentials store. +func NewStore() (*Store, error) { + secrets, err := keychain.NewAccess("bridge") + return &Store{ + secrets: secrets, + }, err +} + +func (s *Store) Add(userID, userName, apiToken, mailboxPassword string, emails []string) (creds *Credentials, err error) { + storeLocker.Lock() + defer storeLocker.Unlock() + + log.WithFields(logrus.Fields{ + "user": userID, + "username": userName, + "emails": emails, + }).Trace("Adding new credentials") + + if err = s.checkKeychain(); err != nil { + return + } + + creds = &Credentials{ + UserID: userID, + Name: userName, + APIToken: apiToken, + MailboxPassword: mailboxPassword, + IsHidden: false, + } + + creds.SetEmailList(emails) + + var has bool + if has, err = s.has(userID); err != nil { + log.WithField("userID", userID).WithError(err).Error("Could not check if user credentials already exist") + return + } + + if has { + log.Info("Updating credentials of existing user") + currentCredentials, err := s.get(userID) + if err != nil { + return nil, err + } + creds.BridgePassword = currentCredentials.BridgePassword + creds.IsCombinedAddressMode = currentCredentials.IsCombinedAddressMode + creds.Timestamp = currentCredentials.Timestamp + } else { + log.Info("Generating credentials for new user") + creds.BridgePassword = generatePassword() + creds.IsCombinedAddressMode = true + creds.Timestamp = time.Now().Unix() + } + + if err = s.saveCredentials(creds); err != nil { + return + } + + return creds, err +} + +func (s *Store) SwitchAddressMode(userID string) error { + storeLocker.Lock() + defer storeLocker.Unlock() + + credentials, err := s.get(userID) + if err != nil { + return err + } + + credentials.IsCombinedAddressMode = !credentials.IsCombinedAddressMode + credentials.BridgePassword = generatePassword() + + return s.saveCredentials(credentials) +} + +func (s *Store) UpdateEmails(userID string, emails []string) error { + storeLocker.Lock() + defer storeLocker.Unlock() + + credentials, err := s.get(userID) + if err != nil { + return err + } + + credentials.SetEmailList(emails) + + return s.saveCredentials(credentials) +} + +func (s *Store) UpdateToken(userID, apiToken string) error { + storeLocker.Lock() + defer storeLocker.Unlock() + + credentials, err := s.get(userID) + if err != nil { + return err + } + + credentials.APIToken = apiToken + + return s.saveCredentials(credentials) +} + +func (s *Store) Logout(userID string) error { + storeLocker.Lock() + defer storeLocker.Unlock() + + credentials, err := s.get(userID) + if err != nil { + return err + } + + credentials.Logout() + + return s.saveCredentials(credentials) +} + +// List returns a list of usernames that have credentials stored. +func (s *Store) List() (userIDs []string, err error) { + storeLocker.RLock() + defer storeLocker.RUnlock() + + log.Trace("Listing credentials in credentials store") + + if err = s.checkKeychain(); err != nil { + return + } + + var allUserIDs []string + if allUserIDs, err = s.secrets.List(); err != nil { + log.WithError(err).Error("Could not list credentials") + return + } + + credentialList := []*Credentials{} + for _, userID := range allUserIDs { + creds, getErr := s.get(userID) + if getErr != nil { + log.WithField("userID", userID).WithError(getErr).Warn("Failed to get credentials") + continue + } + + if creds.Timestamp == 0 { + continue + } + + credentialList = append(credentialList, creds) + } + + sort.Slice(credentialList, func(i, j int) bool { + return credentialList[i].Timestamp < credentialList[j].Timestamp + }) + + for _, credentials := range credentialList { + userIDs = append(userIDs, credentials.UserID) + } + + return userIDs, err +} + +func (s *Store) GetAndCheckPassword(userID, password string) (creds *Credentials, err error) { + storeLocker.RLock() + defer storeLocker.RUnlock() + + log.WithFields(logrus.Fields{ + "userID": userID, + }).Debug("Checking bridge password") + + credentials, err := s.Get(userID) + if err != nil { + return nil, err + } + + if err := credentials.CheckPassword(password); err != nil { + log.WithFields(logrus.Fields{ + "userID": userID, + "err": err, + }).Debug("Incorrect bridge password") + + return nil, err + } + + return credentials, nil +} + +func (s *Store) Get(userID string) (creds *Credentials, err error) { + storeLocker.RLock() + defer storeLocker.RUnlock() + + var has bool + if has, err = s.has(userID); err != nil { + log.WithError(err).Error("Could not check for credentials") + return + } + + if !has { + err = errors.New("no credentials found for given userID") + return + } + + return s.get(userID) +} + +func (s *Store) has(userID string) (has bool, err error) { + if err = s.checkKeychain(); err != nil { + return + } + + var ids []string + if ids, err = s.secrets.List(); err != nil { + log.WithError(err).Error("Could not list credentials") + return + } + + for _, id := range ids { + if id == userID { + has = true + } + } + + return +} + +func (s *Store) get(userID string) (creds *Credentials, err error) { + log := log.WithField("user", userID) + + if err = s.checkKeychain(); err != nil { + return + } + + secret, err := s.secrets.Get(userID) + if err != nil { + log.WithError(err).Error("Could not get credentials from native keychain") + return + } + + credentials := &Credentials{UserID: userID} + if err = credentials.Unmarshal(secret); err != nil { + err = fmt.Errorf("backend/credentials: malformed secret: %v", err) + _ = s.secrets.Delete(userID) + log.WithError(err).Error("Could not unmarshal secret") + return + } + + return credentials, nil +} + +// saveCredentials encrypts and saves password to the keychain store. +func (s *Store) saveCredentials(credentials *Credentials) (err error) { + if err = s.checkKeychain(); err != nil { + return + } + + credentials.Version = keychain.KeychainVersion + + return s.secrets.Put(credentials.UserID, credentials.Marshal()) +} + +func (s *Store) checkKeychain() (err error) { + if s.secrets == nil { + err = keychain.ErrNoKeychainInstalled + log.WithError(err).Error("Store is unusable") + } + + return +} + +// Delete removes credentials from the store. +func (s *Store) Delete(userID string) (err error) { + storeLocker.Lock() + defer storeLocker.Unlock() + + if err = s.checkKeychain(); err != nil { + return + } + + return s.secrets.Delete(userID) +} diff --git a/internal/bridge/credentials/store_test.go b/internal/bridge/credentials/store_test.go new file mode 100644 index 00000000..b88f2214 --- /dev/null +++ b/internal/bridge/credentials/store_test.go @@ -0,0 +1,297 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package credentials + +import ( + "bytes" + "encoding/base64" + "encoding/gob" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testSep = "\n" +const secretFormat = "%v" + testSep + // UserID, + "%v" + testSep + // Name, + "%v" + testSep + // Emails, + "%v" + testSep + // APIToken, + "%v" + testSep + // Mailbox, + "%v" + testSep + // BridgePassword, + "%v" + testSep + // Version string + "%v" + testSep + // Timestamp, + "%v" + testSep + // IsHidden, + "%v" // IsCombinedAddressMode + +// the best would be to run this test on mac, win, and linux separately + +type testCredentials struct { + UserID, + Name, + Emails, + APIToken, + Mailbox, + BridgePassword, + Version string + Timestamp int64 + IsHidden, + IsCombinedAddressMode bool +} + +func init() { //nolint[gochecknoinits] + gob.Register(testCredentials{}) +} + +func (s *testCredentials) MarshalGob() string { + buf := bytes.Buffer{} + enc := gob.NewEncoder(&buf) + if err := enc.Encode(s); err != nil { + return "" + } + fmt.Printf("MarshalGob: %#v\n", buf.String()) + return base64.StdEncoding.EncodeToString(buf.Bytes()) +} + +func (s *testCredentials) Clear() { + s.UserID = "" + s.Name = "" + s.Emails = "" + s.APIToken = "" + s.Mailbox = "" + s.BridgePassword = "" + s.Version = "" + s.Timestamp = 0 + s.IsHidden = false + s.IsCombinedAddressMode = false +} + +func (s *testCredentials) UnmarshalGob(secret string) error { + s.Clear() + b, err := base64.StdEncoding.DecodeString(secret) + if err != nil { + fmt.Println("decode base64", b) + return err + } + buf := bytes.NewBuffer(b) + dec := gob.NewDecoder(buf) + if err = dec.Decode(s); err != nil { + fmt.Println("decode gob", b, buf.Bytes()) + return err + } + return nil +} + +func (s *testCredentials) ToJSON() string { + if b, err := json.Marshal(s); err == nil { + fmt.Printf("MarshalJSON: %#v\n", string(b)) + return base64.StdEncoding.EncodeToString(b) + } + return "" +} + +func (s *testCredentials) FromJSON(secret string) error { + b, err := base64.StdEncoding.DecodeString(secret) + if err != nil { + return err + } + if err = json.Unmarshal(b, s); err == nil { + return nil + } + return err +} + +func (s *testCredentials) MarshalFmt() string { + buf := bytes.Buffer{} + fmt.Fprintf( + &buf, secretFormat, + s.UserID, + s.Name, + s.Emails, + s.APIToken, + s.Mailbox, + s.BridgePassword, + s.Version, + s.Timestamp, + s.IsHidden, + s.IsCombinedAddressMode, + ) + fmt.Printf("MarshalFmt: %#v\n", buf.String()) + return base64.StdEncoding.EncodeToString(buf.Bytes()) +} + +func (s *testCredentials) UnmarshalFmt(secret string) error { + b, err := base64.StdEncoding.DecodeString(secret) + if err != nil { + return err + } + buf := bytes.NewBuffer(b) + fmt.Println("decode fmt", b, buf.Bytes()) + _, err = fmt.Fscanf( + buf, secretFormat, + &s.UserID, + &s.Name, + &s.Emails, + &s.APIToken, + &s.Mailbox, + &s.BridgePassword, + &s.Version, + &s.Timestamp, + &s.IsHidden, + &s.IsCombinedAddressMode, + ) + if err != nil { + return err + } + return nil +} + +func (s *testCredentials) MarshalStrings() string { // this is the most space efficient + items := []string{ + s.UserID, // 0 + s.Name, // 1 + s.Emails, // 2 + s.APIToken, // 3 + s.Mailbox, // 4 + s.BridgePassword, // 5 + s.Version, // 6 + } + items = append(items, fmt.Sprint(s.Timestamp)) // 7 + + if s.IsHidden { // 8 + items = append(items, "1") + } else { + items = append(items, "") + } + + if s.IsCombinedAddressMode { // 9 + items = append(items, "1") + } else { + items = append(items, "") + } + + str := strings.Join(items, sep) + + fmt.Printf("MarshalJoin: %#v\n", str) + return base64.StdEncoding.EncodeToString([]byte(str)) +} + +func (s *testCredentials) UnmarshalStrings(secret string) error { + b, err := base64.StdEncoding.DecodeString(secret) + if err != nil { + return err + } + items := strings.Split(string(b), sep) + if len(items) != 10 { + return ErrWrongFormat + } + + s.UserID = items[0] + s.Name = items[1] + s.Emails = items[2] + s.APIToken = items[3] + s.Mailbox = items[4] + s.BridgePassword = items[5] + s.Version = items[6] + if _, err = fmt.Sscanf(items[7], "%d", &s.Timestamp); err != nil { + s.Timestamp = 0 + } + if s.IsHidden = false; items[8] == "1" { + s.IsHidden = true + } + if s.IsCombinedAddressMode = false; items[9] == "1" { + s.IsCombinedAddressMode = true + } + return nil +} + +func (s *testCredentials) IsSame(rhs *testCredentials) bool { + return s.Name == rhs.Name && + s.Emails == rhs.Emails && + s.APIToken == rhs.APIToken && + s.Mailbox == rhs.Mailbox && + s.BridgePassword == rhs.BridgePassword && + s.Version == rhs.Version && + s.Timestamp == rhs.Timestamp && + s.IsHidden == rhs.IsHidden && + s.IsCombinedAddressMode == rhs.IsCombinedAddressMode +} + +func TestMarshalFormats(t *testing.T) { + input := testCredentials{UserID: "007", Emails: "ja@pm.me;jakub@cu.th", Timestamp: 152469263742, IsHidden: true} + fmt.Printf("input %#v\n", input) + + secretStrings := input.MarshalStrings() + fmt.Printf("secretStrings %#v %d\n", secretStrings, len(secretStrings)) + secretGob := input.MarshalGob() + fmt.Printf("secretGob %#v %d\n", secretGob, len(secretGob)) + secretJSON := input.ToJSON() + fmt.Printf("secretJSON %#v %d\n", secretJSON, len(secretJSON)) + secretFmt := input.MarshalFmt() + fmt.Printf("secretFmt %#v %d\n", secretFmt, len(secretFmt)) + + output := testCredentials{APIToken: "refresh"} + require.NoError(t, output.UnmarshalStrings(secretStrings)) + fmt.Printf("strings out %#v \n", output) + require.True(t, input.IsSame(&output), "strings out not same") + + output = testCredentials{APIToken: "refresh"} + require.NoError(t, output.UnmarshalGob(secretGob)) + fmt.Printf("gob out %#v\n \n", output) + assert.Equal(t, input, output) + + output = testCredentials{APIToken: "refresh"} + require.NoError(t, output.FromJSON(secretJSON)) + fmt.Printf("json out %#v \n", output) + require.True(t, input.IsSame(&output), "json out not same") + + /* + // Simple Fscanf not working! + output = testCredentials{APIToken: "refresh"} + require.NoError(t, output.UnmarshalFmt(secretFmt)) + fmt.Printf("fmt out %#v \n", output) + require.True(t, input.IsSame(&output), "fmt out not same") + */ +} + +func TestMarshal(t *testing.T) { + input := Credentials{ + UserID: "", + Name: "007", + Emails: "ja@pm.me;aj@cus.tom", + APIToken: "sdfdsfsdfsdfsdf", + MailboxPassword: "cdcdcdcd", + BridgePassword: "wew123", + Version: "k11", + Timestamp: 152469263742, + IsHidden: true, + IsCombinedAddressMode: false, + } + fmt.Printf("input %#v\n", input) + + secret := input.Marshal() + fmt.Printf("secret %#v %d\n", secret, len(secret)) + + output := Credentials{APIToken: "refresh"} + require.NoError(t, output.Unmarshal(secret)) + fmt.Printf("output %#v\n", output) + assert.Equal(t, input, output) +} diff --git a/internal/bridge/credits.go b/internal/bridge/credits.go new file mode 100644 index 00000000..e9a3d0a5 --- /dev/null +++ b/internal/bridge/credits.go @@ -0,0 +1,22 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Code generated by ./credits.sh at Mon Apr 6 08:14:14 CEST 2020. DO NOT EDIT. + +package bridge + +const Credits = "github.com/0xAX/notificator;github.com/ProtonMail/bcrypt;github.com/ProtonMail/crypto;github.com/ProtonMail/docker-credential-helpers;github.com/ProtonMail/go-appdir;github.com/ProtonMail/go-apple-mobileconfig;github.com/ProtonMail/go-autostart;github.com/ProtonMail/go-imap;github.com/ProtonMail/go-imap-id;github.com/ProtonMail/go-imap-quota;github.com/ProtonMail/go-smtp;github.com/ProtonMail/go-vcard;github.com/ProtonMail/gopenpgp;github.com/abiosoft/ishell;github.com/abiosoft/readline;github.com/allan-simon/go-singleinstance;github.com/andybalholm/cascadia;github.com/certifi/gocertifi;github.com/chzyer/logex;github.com/chzyer/test;github.com/cucumber/godog;github.com/danieljoos/wincred;github.com/docker/docker-credential-helpers;github.com/emersion/go-imap;github.com/emersion/go-imap-appendlimit;github.com/emersion/go-imap-idle;github.com/emersion/go-imap-quota;github.com/emersion/go-imap-specialuse;github.com/emersion/go-sasl;github.com/emersion/go-smtp;github.com/emersion/go-textwrapper;github.com/emersion/go-vcard;github.com/fatih/color;github.com/flynn-archive/go-shlex;github.com/getsentry/raven-go;github.com/go-resty/resty/v2;github.com/golang/mock;github.com/google/go-cmp;github.com/gopherjs/gopherjs;github.com/hashicorp/go-multierror;github.com/jameskeane/bcrypt;github.com/jaytaylor/html2text;github.com/jhillyerd/enmime;github.com/kardianos/osext;github.com/keybase/go-keychain;github.com/logrusorgru/aurora;github.com/miekg/dns;github.com/myesui/uuid;github.com/nsf/jsondiff;github.com/pkg/errors;github.com/sirupsen/logrus;github.com/skratchdot/open-golang;github.com/stretchr/testify;github.com/therecipe/qt;github.com/twinj/uuid;github.com/urfave/cli;go.etcd.io/bbolt;golang.org/x/crypto;golang.org/x/net;golang.org/x/text;gopkg.in/stretchr/testify.v1;;Font Awesome 4.7.0;;Qt 5.13 by Qt group;" diff --git a/internal/bridge/mock_listener.go b/internal/bridge/mock_listener.go new file mode 100644 index 00000000..f1c05e8a --- /dev/null +++ b/internal/bridge/mock_listener.go @@ -0,0 +1,107 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./listener/listener.go + +// Package bridge is a generated GoMock package. +package bridge + +import ( + reflect "reflect" + time "time" + + gomock "github.com/golang/mock/gomock" +) + +// MockListener is a mock of Listener interface +type MockListener struct { + ctrl *gomock.Controller + recorder *MockListenerMockRecorder +} + +// MockListenerMockRecorder is the mock recorder for MockListener +type MockListenerMockRecorder struct { + mock *MockListener +} + +// NewMockListener creates a new mock instance +func NewMockListener(ctrl *gomock.Controller) *MockListener { + mock := &MockListener{ctrl: ctrl} + mock.recorder = &MockListenerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockListener) EXPECT() *MockListenerMockRecorder { + return m.recorder +} + +// SetLimit mocks base method +func (m *MockListener) SetLimit(eventName string, limit time.Duration) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetLimit", eventName, limit) +} + +// SetLimit indicates an expected call of SetLimit +func (mr *MockListenerMockRecorder) SetLimit(eventName, limit interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetLimit", reflect.TypeOf((*MockListener)(nil).SetLimit), eventName, limit) +} + +// Add mocks base method +func (m *MockListener) Add(eventName string, channel chan<- string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Add", eventName, channel) +} + +// Add indicates an expected call of Add +func (mr *MockListenerMockRecorder) Add(eventName, channel interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Add", reflect.TypeOf((*MockListener)(nil).Add), eventName, channel) +} + +// Remove mocks base method +func (m *MockListener) Remove(eventName string, channel chan<- string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Remove", eventName, channel) +} + +// Remove indicates an expected call of Remove +func (mr *MockListenerMockRecorder) Remove(eventName, channel interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Remove", reflect.TypeOf((*MockListener)(nil).Remove), eventName, channel) +} + +// Emit mocks base method +func (m *MockListener) Emit(eventName, data string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Emit", eventName, data) +} + +// Emit indicates an expected call of Emit +func (mr *MockListenerMockRecorder) Emit(eventName, data interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Emit", reflect.TypeOf((*MockListener)(nil).Emit), eventName, data) +} + +// SetBuffer mocks base method +func (m *MockListener) SetBuffer(eventName string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetBuffer", eventName) +} + +// SetBuffer indicates an expected call of SetBuffer +func (mr *MockListenerMockRecorder) SetBuffer(eventName interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetBuffer", reflect.TypeOf((*MockListener)(nil).SetBuffer), eventName) +} + +// RetryEmit mocks base method +func (m *MockListener) RetryEmit(eventName string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "RetryEmit", eventName) +} + +// RetryEmit indicates an expected call of RetryEmit +func (mr *MockListenerMockRecorder) RetryEmit(eventName interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RetryEmit", reflect.TypeOf((*MockListener)(nil).RetryEmit), eventName) +} diff --git a/internal/bridge/mocks/mocks.go b/internal/bridge/mocks/mocks.go new file mode 100644 index 00000000..16d42a7f --- /dev/null +++ b/internal/bridge/mocks/mocks.go @@ -0,0 +1,923 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/ProtonMail/proton-bridge/internal/bridge (interfaces: Configer,PreferenceProvider,PanicHandler,PMAPIProvider,CredentialsStorer) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + credentials "github.com/ProtonMail/proton-bridge/internal/bridge/credentials" + pmapi "github.com/ProtonMail/proton-bridge/pkg/pmapi" + crypto "github.com/ProtonMail/gopenpgp/crypto" + gomock "github.com/golang/mock/gomock" + io "io" + reflect "reflect" +) + +// MockConfiger is a mock of Configer interface +type MockConfiger struct { + ctrl *gomock.Controller + recorder *MockConfigerMockRecorder +} + +// MockConfigerMockRecorder is the mock recorder for MockConfiger +type MockConfigerMockRecorder struct { + mock *MockConfiger +} + +// NewMockConfiger creates a new mock instance +func NewMockConfiger(ctrl *gomock.Controller) *MockConfiger { + mock := &MockConfiger{ctrl: ctrl} + mock.recorder = &MockConfigerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockConfiger) EXPECT() *MockConfigerMockRecorder { + return m.recorder +} + +// ClearData mocks base method +func (m *MockConfiger) ClearData() error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClearData") + ret0, _ := ret[0].(error) + return ret0 +} + +// ClearData indicates an expected call of ClearData +func (mr *MockConfigerMockRecorder) ClearData() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClearData", reflect.TypeOf((*MockConfiger)(nil).ClearData)) +} + +// GetAPIConfig mocks base method +func (m *MockConfiger) GetAPIConfig() *pmapi.ClientConfig { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAPIConfig") + ret0, _ := ret[0].(*pmapi.ClientConfig) + return ret0 +} + +// GetAPIConfig indicates an expected call of GetAPIConfig +func (mr *MockConfigerMockRecorder) GetAPIConfig() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAPIConfig", reflect.TypeOf((*MockConfiger)(nil).GetAPIConfig)) +} + +// GetDBDir mocks base method +func (m *MockConfiger) GetDBDir() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetDBDir") + ret0, _ := ret[0].(string) + return ret0 +} + +// GetDBDir indicates an expected call of GetDBDir +func (mr *MockConfigerMockRecorder) GetDBDir() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDBDir", reflect.TypeOf((*MockConfiger)(nil).GetDBDir)) +} + +// GetIMAPCachePath mocks base method +func (m *MockConfiger) GetIMAPCachePath() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetIMAPCachePath") + ret0, _ := ret[0].(string) + return ret0 +} + +// GetIMAPCachePath indicates an expected call of GetIMAPCachePath +func (mr *MockConfigerMockRecorder) GetIMAPCachePath() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetIMAPCachePath", reflect.TypeOf((*MockConfiger)(nil).GetIMAPCachePath)) +} + +// MockPreferenceProvider is a mock of PreferenceProvider interface +type MockPreferenceProvider struct { + ctrl *gomock.Controller + recorder *MockPreferenceProviderMockRecorder +} + +// MockPreferenceProviderMockRecorder is the mock recorder for MockPreferenceProvider +type MockPreferenceProviderMockRecorder struct { + mock *MockPreferenceProvider +} + +// NewMockPreferenceProvider creates a new mock instance +func NewMockPreferenceProvider(ctrl *gomock.Controller) *MockPreferenceProvider { + mock := &MockPreferenceProvider{ctrl: ctrl} + mock.recorder = &MockPreferenceProviderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockPreferenceProvider) EXPECT() *MockPreferenceProviderMockRecorder { + return m.recorder +} + +// Get mocks base method +func (m *MockPreferenceProvider) Get(arg0 string) string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", arg0) + ret0, _ := ret[0].(string) + return ret0 +} + +// Get indicates an expected call of Get +func (mr *MockPreferenceProviderMockRecorder) Get(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockPreferenceProvider)(nil).Get), arg0) +} + +// GetBool mocks base method +func (m *MockPreferenceProvider) GetBool(arg0 string) bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetBool", arg0) + ret0, _ := ret[0].(bool) + return ret0 +} + +// GetBool indicates an expected call of GetBool +func (mr *MockPreferenceProviderMockRecorder) GetBool(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBool", reflect.TypeOf((*MockPreferenceProvider)(nil).GetBool), arg0) +} + +// GetInt mocks base method +func (m *MockPreferenceProvider) GetInt(arg0 string) int { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetInt", arg0) + ret0, _ := ret[0].(int) + return ret0 +} + +// GetInt indicates an expected call of GetInt +func (mr *MockPreferenceProviderMockRecorder) GetInt(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInt", reflect.TypeOf((*MockPreferenceProvider)(nil).GetInt), arg0) +} + +// Set mocks base method +func (m *MockPreferenceProvider) Set(arg0, arg1 string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Set", arg0, arg1) +} + +// Set indicates an expected call of Set +func (mr *MockPreferenceProviderMockRecorder) Set(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Set", reflect.TypeOf((*MockPreferenceProvider)(nil).Set), arg0, arg1) +} + +// MockPanicHandler is a mock of PanicHandler interface +type MockPanicHandler struct { + ctrl *gomock.Controller + recorder *MockPanicHandlerMockRecorder +} + +// MockPanicHandlerMockRecorder is the mock recorder for MockPanicHandler +type MockPanicHandlerMockRecorder struct { + mock *MockPanicHandler +} + +// NewMockPanicHandler creates a new mock instance +func NewMockPanicHandler(ctrl *gomock.Controller) *MockPanicHandler { + mock := &MockPanicHandler{ctrl: ctrl} + mock.recorder = &MockPanicHandlerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockPanicHandler) EXPECT() *MockPanicHandlerMockRecorder { + return m.recorder +} + +// HandlePanic mocks base method +func (m *MockPanicHandler) HandlePanic() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "HandlePanic") +} + +// HandlePanic indicates an expected call of HandlePanic +func (mr *MockPanicHandlerMockRecorder) HandlePanic() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandlePanic", reflect.TypeOf((*MockPanicHandler)(nil).HandlePanic)) +} + +// MockPMAPIProvider is a mock of PMAPIProvider interface +type MockPMAPIProvider struct { + ctrl *gomock.Controller + recorder *MockPMAPIProviderMockRecorder +} + +// MockPMAPIProviderMockRecorder is the mock recorder for MockPMAPIProvider +type MockPMAPIProviderMockRecorder struct { + mock *MockPMAPIProvider +} + +// NewMockPMAPIProvider creates a new mock instance +func NewMockPMAPIProvider(ctrl *gomock.Controller) *MockPMAPIProvider { + mock := &MockPMAPIProvider{ctrl: ctrl} + mock.recorder = &MockPMAPIProviderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockPMAPIProvider) EXPECT() *MockPMAPIProviderMockRecorder { + return m.recorder +} + +// Addresses mocks base method +func (m *MockPMAPIProvider) Addresses() pmapi.AddressList { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Addresses") + ret0, _ := ret[0].(pmapi.AddressList) + return ret0 +} + +// Addresses indicates an expected call of Addresses +func (mr *MockPMAPIProviderMockRecorder) Addresses() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Addresses", reflect.TypeOf((*MockPMAPIProvider)(nil).Addresses)) +} + +// Auth mocks base method +func (m *MockPMAPIProvider) Auth(arg0, arg1 string, arg2 *pmapi.AuthInfo) (*pmapi.Auth, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Auth", arg0, arg1, arg2) + ret0, _ := ret[0].(*pmapi.Auth) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Auth indicates an expected call of Auth +func (mr *MockPMAPIProviderMockRecorder) Auth(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Auth", reflect.TypeOf((*MockPMAPIProvider)(nil).Auth), arg0, arg1, arg2) +} + +// Auth2FA mocks base method +func (m *MockPMAPIProvider) Auth2FA(arg0 string, arg1 *pmapi.Auth) (*pmapi.Auth2FA, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Auth2FA", arg0, arg1) + ret0, _ := ret[0].(*pmapi.Auth2FA) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Auth2FA indicates an expected call of Auth2FA +func (mr *MockPMAPIProviderMockRecorder) Auth2FA(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Auth2FA", reflect.TypeOf((*MockPMAPIProvider)(nil).Auth2FA), arg0, arg1) +} + +// AuthInfo mocks base method +func (m *MockPMAPIProvider) AuthInfo(arg0 string) (*pmapi.AuthInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AuthInfo", arg0) + ret0, _ := ret[0].(*pmapi.AuthInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AuthInfo indicates an expected call of AuthInfo +func (mr *MockPMAPIProviderMockRecorder) AuthInfo(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthInfo", reflect.TypeOf((*MockPMAPIProvider)(nil).AuthInfo), arg0) +} + +// AuthRefresh mocks base method +func (m *MockPMAPIProvider) AuthRefresh(arg0 string) (*pmapi.Auth, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AuthRefresh", arg0) + ret0, _ := ret[0].(*pmapi.Auth) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AuthRefresh indicates an expected call of AuthRefresh +func (mr *MockPMAPIProviderMockRecorder) AuthRefresh(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthRefresh", reflect.TypeOf((*MockPMAPIProvider)(nil).AuthRefresh), arg0) +} + +// CountMessages mocks base method +func (m *MockPMAPIProvider) CountMessages(arg0 string) ([]*pmapi.MessagesCount, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CountMessages", arg0) + ret0, _ := ret[0].([]*pmapi.MessagesCount) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CountMessages indicates an expected call of CountMessages +func (mr *MockPMAPIProviderMockRecorder) CountMessages(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountMessages", reflect.TypeOf((*MockPMAPIProvider)(nil).CountMessages), arg0) +} + +// CreateAttachment mocks base method +func (m *MockPMAPIProvider) CreateAttachment(arg0 *pmapi.Attachment, arg1, arg2 io.Reader) (*pmapi.Attachment, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateAttachment", arg0, arg1, arg2) + ret0, _ := ret[0].(*pmapi.Attachment) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateAttachment indicates an expected call of CreateAttachment +func (mr *MockPMAPIProviderMockRecorder) CreateAttachment(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAttachment", reflect.TypeOf((*MockPMAPIProvider)(nil).CreateAttachment), arg0, arg1, arg2) +} + +// CreateDraft mocks base method +func (m *MockPMAPIProvider) CreateDraft(arg0 *pmapi.Message, arg1 string, arg2 int) (*pmapi.Message, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateDraft", arg0, arg1, arg2) + ret0, _ := ret[0].(*pmapi.Message) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateDraft indicates an expected call of CreateDraft +func (mr *MockPMAPIProviderMockRecorder) CreateDraft(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateDraft", reflect.TypeOf((*MockPMAPIProvider)(nil).CreateDraft), arg0, arg1, arg2) +} + +// CreateLabel mocks base method +func (m *MockPMAPIProvider) CreateLabel(arg0 *pmapi.Label) (*pmapi.Label, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateLabel", arg0) + ret0, _ := ret[0].(*pmapi.Label) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateLabel indicates an expected call of CreateLabel +func (mr *MockPMAPIProviderMockRecorder) CreateLabel(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateLabel", reflect.TypeOf((*MockPMAPIProvider)(nil).CreateLabel), arg0) +} + +// CurrentUser mocks base method +func (m *MockPMAPIProvider) CurrentUser() (*pmapi.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CurrentUser") + ret0, _ := ret[0].(*pmapi.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CurrentUser indicates an expected call of CurrentUser +func (mr *MockPMAPIProviderMockRecorder) CurrentUser() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CurrentUser", reflect.TypeOf((*MockPMAPIProvider)(nil).CurrentUser)) +} + +// DecryptAndVerifyCards mocks base method +func (m *MockPMAPIProvider) DecryptAndVerifyCards(arg0 []pmapi.Card) ([]pmapi.Card, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DecryptAndVerifyCards", arg0) + ret0, _ := ret[0].([]pmapi.Card) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DecryptAndVerifyCards indicates an expected call of DecryptAndVerifyCards +func (mr *MockPMAPIProviderMockRecorder) DecryptAndVerifyCards(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DecryptAndVerifyCards", reflect.TypeOf((*MockPMAPIProvider)(nil).DecryptAndVerifyCards), arg0) +} + +// DeleteLabel mocks base method +func (m *MockPMAPIProvider) DeleteLabel(arg0 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteLabel", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteLabel indicates an expected call of DeleteLabel +func (mr *MockPMAPIProviderMockRecorder) DeleteLabel(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteLabel", reflect.TypeOf((*MockPMAPIProvider)(nil).DeleteLabel), arg0) +} + +// DeleteMessages mocks base method +func (m *MockPMAPIProvider) DeleteMessages(arg0 []string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteMessages", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteMessages indicates an expected call of DeleteMessages +func (mr *MockPMAPIProviderMockRecorder) DeleteMessages(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteMessages", reflect.TypeOf((*MockPMAPIProvider)(nil).DeleteMessages), arg0) +} + +// EmptyFolder mocks base method +func (m *MockPMAPIProvider) EmptyFolder(arg0, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "EmptyFolder", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// EmptyFolder indicates an expected call of EmptyFolder +func (mr *MockPMAPIProviderMockRecorder) EmptyFolder(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EmptyFolder", reflect.TypeOf((*MockPMAPIProvider)(nil).EmptyFolder), arg0, arg1) +} + +// GetAttachment mocks base method +func (m *MockPMAPIProvider) GetAttachment(arg0 string) (io.ReadCloser, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAttachment", arg0) + ret0, _ := ret[0].(io.ReadCloser) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAttachment indicates an expected call of GetAttachment +func (mr *MockPMAPIProviderMockRecorder) GetAttachment(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAttachment", reflect.TypeOf((*MockPMAPIProvider)(nil).GetAttachment), arg0) +} + +// GetContactByID mocks base method +func (m *MockPMAPIProvider) GetContactByID(arg0 string) (pmapi.Contact, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetContactByID", arg0) + ret0, _ := ret[0].(pmapi.Contact) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetContactByID indicates an expected call of GetContactByID +func (mr *MockPMAPIProviderMockRecorder) GetContactByID(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetContactByID", reflect.TypeOf((*MockPMAPIProvider)(nil).GetContactByID), arg0) +} + +// GetContactEmailByEmail mocks base method +func (m *MockPMAPIProvider) GetContactEmailByEmail(arg0 string, arg1, arg2 int) ([]pmapi.ContactEmail, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetContactEmailByEmail", arg0, arg1, arg2) + ret0, _ := ret[0].([]pmapi.ContactEmail) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetContactEmailByEmail indicates an expected call of GetContactEmailByEmail +func (mr *MockPMAPIProviderMockRecorder) GetContactEmailByEmail(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetContactEmailByEmail", reflect.TypeOf((*MockPMAPIProvider)(nil).GetContactEmailByEmail), arg0, arg1, arg2) +} + +// GetEvent mocks base method +func (m *MockPMAPIProvider) GetEvent(arg0 string) (*pmapi.Event, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetEvent", arg0) + ret0, _ := ret[0].(*pmapi.Event) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetEvent indicates an expected call of GetEvent +func (mr *MockPMAPIProviderMockRecorder) GetEvent(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEvent", reflect.TypeOf((*MockPMAPIProvider)(nil).GetEvent), arg0) +} + +// GetMailSettings mocks base method +func (m *MockPMAPIProvider) GetMailSettings() (pmapi.MailSettings, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetMailSettings") + ret0, _ := ret[0].(pmapi.MailSettings) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetMailSettings indicates an expected call of GetMailSettings +func (mr *MockPMAPIProviderMockRecorder) GetMailSettings() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMailSettings", reflect.TypeOf((*MockPMAPIProvider)(nil).GetMailSettings)) +} + +// GetMessage mocks base method +func (m *MockPMAPIProvider) GetMessage(arg0 string) (*pmapi.Message, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetMessage", arg0) + ret0, _ := ret[0].(*pmapi.Message) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetMessage indicates an expected call of GetMessage +func (mr *MockPMAPIProviderMockRecorder) GetMessage(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMessage", reflect.TypeOf((*MockPMAPIProvider)(nil).GetMessage), arg0) +} + +// GetPublicKeysForEmail mocks base method +func (m *MockPMAPIProvider) GetPublicKeysForEmail(arg0 string) ([]pmapi.PublicKey, bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPublicKeysForEmail", arg0) + ret0, _ := ret[0].([]pmapi.PublicKey) + ret1, _ := ret[1].(bool) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetPublicKeysForEmail indicates an expected call of GetPublicKeysForEmail +func (mr *MockPMAPIProviderMockRecorder) GetPublicKeysForEmail(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPublicKeysForEmail", reflect.TypeOf((*MockPMAPIProvider)(nil).GetPublicKeysForEmail), arg0) +} + +// Import mocks base method +func (m *MockPMAPIProvider) Import(arg0 []*pmapi.ImportMsgReq) ([]*pmapi.ImportMsgRes, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Import", arg0) + ret0, _ := ret[0].([]*pmapi.ImportMsgRes) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Import indicates an expected call of Import +func (mr *MockPMAPIProviderMockRecorder) Import(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Import", reflect.TypeOf((*MockPMAPIProvider)(nil).Import), arg0) +} + +// KeyRingForAddressID mocks base method +func (m *MockPMAPIProvider) KeyRingForAddressID(arg0 string) *crypto.KeyRing { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "KeyRingForAddressID", arg0) + ret0, _ := ret[0].(*crypto.KeyRing) + return ret0 +} + +// KeyRingForAddressID indicates an expected call of KeyRingForAddressID +func (mr *MockPMAPIProviderMockRecorder) KeyRingForAddressID(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "KeyRingForAddressID", reflect.TypeOf((*MockPMAPIProvider)(nil).KeyRingForAddressID), arg0) +} + +// LabelMessages mocks base method +func (m *MockPMAPIProvider) LabelMessages(arg0 []string, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LabelMessages", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// LabelMessages indicates an expected call of LabelMessages +func (mr *MockPMAPIProviderMockRecorder) LabelMessages(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LabelMessages", reflect.TypeOf((*MockPMAPIProvider)(nil).LabelMessages), arg0, arg1) +} + +// ListLabels mocks base method +func (m *MockPMAPIProvider) ListLabels() ([]*pmapi.Label, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListLabels") + ret0, _ := ret[0].([]*pmapi.Label) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListLabels indicates an expected call of ListLabels +func (mr *MockPMAPIProviderMockRecorder) ListLabels() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListLabels", reflect.TypeOf((*MockPMAPIProvider)(nil).ListLabels)) +} + +// ListMessages mocks base method +func (m *MockPMAPIProvider) ListMessages(arg0 *pmapi.MessagesFilter) ([]*pmapi.Message, int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListMessages", arg0) + ret0, _ := ret[0].([]*pmapi.Message) + ret1, _ := ret[1].(int) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// ListMessages indicates an expected call of ListMessages +func (mr *MockPMAPIProviderMockRecorder) ListMessages(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListMessages", reflect.TypeOf((*MockPMAPIProvider)(nil).ListMessages), arg0) +} + +// Logout mocks base method +func (m *MockPMAPIProvider) Logout() error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Logout") + ret0, _ := ret[0].(error) + return ret0 +} + +// Logout indicates an expected call of Logout +func (mr *MockPMAPIProviderMockRecorder) Logout() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Logout", reflect.TypeOf((*MockPMAPIProvider)(nil).Logout)) +} + +// MarkMessagesRead mocks base method +func (m *MockPMAPIProvider) MarkMessagesRead(arg0 []string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MarkMessagesRead", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// MarkMessagesRead indicates an expected call of MarkMessagesRead +func (mr *MockPMAPIProviderMockRecorder) MarkMessagesRead(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkMessagesRead", reflect.TypeOf((*MockPMAPIProvider)(nil).MarkMessagesRead), arg0) +} + +// MarkMessagesUnread mocks base method +func (m *MockPMAPIProvider) MarkMessagesUnread(arg0 []string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MarkMessagesUnread", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// MarkMessagesUnread indicates an expected call of MarkMessagesUnread +func (mr *MockPMAPIProviderMockRecorder) MarkMessagesUnread(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkMessagesUnread", reflect.TypeOf((*MockPMAPIProvider)(nil).MarkMessagesUnread), arg0) +} + +// ReportBugWithEmailClient mocks base method +func (m *MockPMAPIProvider) ReportBugWithEmailClient(arg0, arg1, arg2, arg3, arg4, arg5, arg6 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReportBugWithEmailClient", arg0, arg1, arg2, arg3, arg4, arg5, arg6) + ret0, _ := ret[0].(error) + return ret0 +} + +// ReportBugWithEmailClient indicates an expected call of ReportBugWithEmailClient +func (mr *MockPMAPIProviderMockRecorder) ReportBugWithEmailClient(arg0, arg1, arg2, arg3, arg4, arg5, arg6 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReportBugWithEmailClient", reflect.TypeOf((*MockPMAPIProvider)(nil).ReportBugWithEmailClient), arg0, arg1, arg2, arg3, arg4, arg5, arg6) +} + +// SendMessage mocks base method +func (m *MockPMAPIProvider) SendMessage(arg0 string, arg1 *pmapi.SendMessageReq) (*pmapi.Message, *pmapi.Message, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SendMessage", arg0, arg1) + ret0, _ := ret[0].(*pmapi.Message) + ret1, _ := ret[1].(*pmapi.Message) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// SendMessage indicates an expected call of SendMessage +func (mr *MockPMAPIProviderMockRecorder) SendMessage(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendMessage", reflect.TypeOf((*MockPMAPIProvider)(nil).SendMessage), arg0, arg1) +} + +// SendSimpleMetric mocks base method +func (m *MockPMAPIProvider) SendSimpleMetric(arg0, arg1, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SendSimpleMetric", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// SendSimpleMetric indicates an expected call of SendSimpleMetric +func (mr *MockPMAPIProviderMockRecorder) SendSimpleMetric(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendSimpleMetric", reflect.TypeOf((*MockPMAPIProvider)(nil).SendSimpleMetric), arg0, arg1, arg2) +} + +// SetAuths mocks base method +func (m *MockPMAPIProvider) SetAuths(arg0 chan<- *pmapi.Auth) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetAuths", arg0) +} + +// SetAuths indicates an expected call of SetAuths +func (mr *MockPMAPIProviderMockRecorder) SetAuths(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetAuths", reflect.TypeOf((*MockPMAPIProvider)(nil).SetAuths), arg0) +} + +// UnlabelMessages mocks base method +func (m *MockPMAPIProvider) UnlabelMessages(arg0 []string, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UnlabelMessages", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// UnlabelMessages indicates an expected call of UnlabelMessages +func (mr *MockPMAPIProviderMockRecorder) UnlabelMessages(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnlabelMessages", reflect.TypeOf((*MockPMAPIProvider)(nil).UnlabelMessages), arg0, arg1) +} + +// Unlock mocks base method +func (m *MockPMAPIProvider) Unlock(arg0 string) (*crypto.KeyRing, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Unlock", arg0) + ret0, _ := ret[0].(*crypto.KeyRing) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Unlock indicates an expected call of Unlock +func (mr *MockPMAPIProviderMockRecorder) Unlock(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Unlock", reflect.TypeOf((*MockPMAPIProvider)(nil).Unlock), arg0) +} + +// UnlockAddresses mocks base method +func (m *MockPMAPIProvider) UnlockAddresses(arg0 []byte) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UnlockAddresses", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// UnlockAddresses indicates an expected call of UnlockAddresses +func (mr *MockPMAPIProviderMockRecorder) UnlockAddresses(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnlockAddresses", reflect.TypeOf((*MockPMAPIProvider)(nil).UnlockAddresses), arg0) +} + +// UpdateLabel mocks base method +func (m *MockPMAPIProvider) UpdateLabel(arg0 *pmapi.Label) (*pmapi.Label, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateLabel", arg0) + ret0, _ := ret[0].(*pmapi.Label) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateLabel indicates an expected call of UpdateLabel +func (mr *MockPMAPIProviderMockRecorder) UpdateLabel(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateLabel", reflect.TypeOf((*MockPMAPIProvider)(nil).UpdateLabel), arg0) +} + +// UpdateUser mocks base method +func (m *MockPMAPIProvider) UpdateUser() (*pmapi.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUser") + ret0, _ := ret[0].(*pmapi.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateUser indicates an expected call of UpdateUser +func (mr *MockPMAPIProviderMockRecorder) UpdateUser() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUser", reflect.TypeOf((*MockPMAPIProvider)(nil).UpdateUser)) +} + +// MockCredentialsStorer is a mock of CredentialsStorer interface +type MockCredentialsStorer struct { + ctrl *gomock.Controller + recorder *MockCredentialsStorerMockRecorder +} + +// MockCredentialsStorerMockRecorder is the mock recorder for MockCredentialsStorer +type MockCredentialsStorerMockRecorder struct { + mock *MockCredentialsStorer +} + +// NewMockCredentialsStorer creates a new mock instance +func NewMockCredentialsStorer(ctrl *gomock.Controller) *MockCredentialsStorer { + mock := &MockCredentialsStorer{ctrl: ctrl} + mock.recorder = &MockCredentialsStorerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockCredentialsStorer) EXPECT() *MockCredentialsStorerMockRecorder { + return m.recorder +} + +// Add mocks base method +func (m *MockCredentialsStorer) Add(arg0, arg1, arg2, arg3 string, arg4 []string) (*credentials.Credentials, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Add", arg0, arg1, arg2, arg3, arg4) + ret0, _ := ret[0].(*credentials.Credentials) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Add indicates an expected call of Add +func (mr *MockCredentialsStorerMockRecorder) Add(arg0, arg1, arg2, arg3, arg4 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Add", reflect.TypeOf((*MockCredentialsStorer)(nil).Add), arg0, arg1, arg2, arg3, arg4) +} + +// Delete mocks base method +func (m *MockCredentialsStorer) Delete(arg0 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete +func (mr *MockCredentialsStorerMockRecorder) Delete(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockCredentialsStorer)(nil).Delete), arg0) +} + +// Get mocks base method +func (m *MockCredentialsStorer) Get(arg0 string) (*credentials.Credentials, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", arg0) + ret0, _ := ret[0].(*credentials.Credentials) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get +func (mr *MockCredentialsStorerMockRecorder) Get(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockCredentialsStorer)(nil).Get), arg0) +} + +// List mocks base method +func (m *MockCredentialsStorer) List() ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "List") + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List +func (mr *MockCredentialsStorerMockRecorder) List() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockCredentialsStorer)(nil).List)) +} + +// Logout mocks base method +func (m *MockCredentialsStorer) Logout(arg0 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Logout", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// Logout indicates an expected call of Logout +func (mr *MockCredentialsStorerMockRecorder) Logout(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Logout", reflect.TypeOf((*MockCredentialsStorer)(nil).Logout), arg0) +} + +// SwitchAddressMode mocks base method +func (m *MockCredentialsStorer) SwitchAddressMode(arg0 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SwitchAddressMode", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// SwitchAddressMode indicates an expected call of SwitchAddressMode +func (mr *MockCredentialsStorerMockRecorder) SwitchAddressMode(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SwitchAddressMode", reflect.TypeOf((*MockCredentialsStorer)(nil).SwitchAddressMode), arg0) +} + +// UpdateEmails mocks base method +func (m *MockCredentialsStorer) UpdateEmails(arg0 string, arg1 []string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateEmails", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateEmails indicates an expected call of UpdateEmails +func (mr *MockCredentialsStorerMockRecorder) UpdateEmails(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateEmails", reflect.TypeOf((*MockCredentialsStorer)(nil).UpdateEmails), arg0, arg1) +} + +// UpdateToken mocks base method +func (m *MockCredentialsStorer) UpdateToken(arg0, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateToken", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateToken indicates an expected call of UpdateToken +func (mr *MockCredentialsStorerMockRecorder) UpdateToken(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateToken", reflect.TypeOf((*MockCredentialsStorer)(nil).UpdateToken), arg0, arg1) +} diff --git a/internal/bridge/release_notes.go b/internal/bridge/release_notes.go new file mode 100644 index 00000000..ff2d2960 --- /dev/null +++ b/internal/bridge/release_notes.go @@ -0,0 +1,34 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Code generated by ./release-notes.sh at Mon Apr 6 08:14:14 CEST 2020. DO NOT EDIT. + +package bridge + +const ReleaseNotes = `NOTE: We recommend to reconfigure your email client after upgrading to ensure the best results with the new draft folder support + +• Faster and more resilient mail synchronization process, especially for large mailboxes +• Added "Alternate Routing" feature to mitigate blocking of Proton Servers +• Added synchronization of draft folder +• Improved event handling when there are frequent changes +• Security improvements for loading dependent libraries +• Minor UI & API communication tweaks +` + +const ReleaseFixedBugs = `• Fixed rare case of sending the same message multiple times in Outlook +• Fixed bug in macOS update process; available from next update +` diff --git a/internal/bridge/types.go b/internal/bridge/types.go new file mode 100644 index 00000000..bcc18a54 --- /dev/null +++ b/internal/bridge/types.go @@ -0,0 +1,105 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package bridge + +import ( + "io" + + pmcrypto "github.com/ProtonMail/gopenpgp/crypto" + "github.com/ProtonMail/proton-bridge/internal/bridge/credentials" + pmapi "github.com/ProtonMail/proton-bridge/pkg/pmapi" // mockgen needs this to be given an explicit import name +) + +type Configer interface { + ClearData() error + GetDBDir() string + GetIMAPCachePath() string + GetAPIConfig() *pmapi.ClientConfig +} + +type PreferenceProvider interface { + Get(key string) string + GetBool(key string) bool + GetInt(key string) int + Set(key string, value string) +} + +type PanicHandler interface { + HandlePanic() +} + +type PMAPIProviderFactory func(string) PMAPIProvider + +type PMAPIProvider interface { + SetAuths(auths chan<- *pmapi.Auth) + Auth(username, password string, info *pmapi.AuthInfo) (*pmapi.Auth, error) + AuthInfo(username string) (*pmapi.AuthInfo, error) + AuthRefresh(token string) (*pmapi.Auth, error) + Unlock(mailboxPassword string) (kr *pmcrypto.KeyRing, err error) + UnlockAddresses(passphrase []byte) error + CurrentUser() (*pmapi.User, error) + UpdateUser() (*pmapi.User, error) + Addresses() pmapi.AddressList + Logout() error + + GetEvent(eventID string) (*pmapi.Event, error) + + CountMessages(addressID string) ([]*pmapi.MessagesCount, error) + ListMessages(filter *pmapi.MessagesFilter) ([]*pmapi.Message, int, error) + GetMessage(apiID string) (*pmapi.Message, error) + Import([]*pmapi.ImportMsgReq) ([]*pmapi.ImportMsgRes, error) + DeleteMessages(apiIDs []string) error + LabelMessages(apiIDs []string, labelID string) error + UnlabelMessages(apiIDs []string, labelID string) error + MarkMessagesRead(apiIDs []string) error + MarkMessagesUnread(apiIDs []string) error + + ListLabels() ([]*pmapi.Label, error) + CreateLabel(label *pmapi.Label) (*pmapi.Label, error) + UpdateLabel(label *pmapi.Label) (*pmapi.Label, error) + DeleteLabel(labelID string) error + EmptyFolder(labelID string, addressID string) error + + ReportBugWithEmailClient(os, osVersion, title, description, username, email, emailClient string) error + SendSimpleMetric(category, action, label string) error + + Auth2FA(twoFactorCode string, auth *pmapi.Auth) (*pmapi.Auth2FA, error) + + GetMailSettings() (pmapi.MailSettings, error) + GetContactEmailByEmail(string, int, int) ([]pmapi.ContactEmail, error) + GetContactByID(string) (pmapi.Contact, error) + DecryptAndVerifyCards([]pmapi.Card) ([]pmapi.Card, error) + GetPublicKeysForEmail(string) ([]pmapi.PublicKey, bool, error) + SendMessage(string, *pmapi.SendMessageReq) (sent, parent *pmapi.Message, err error) + CreateDraft(m *pmapi.Message, parent string, action int) (created *pmapi.Message, err error) + CreateAttachment(att *pmapi.Attachment, r io.Reader, sig io.Reader) (created *pmapi.Attachment, err error) + KeyRingForAddressID(string) (kr *pmcrypto.KeyRing) + + GetAttachment(id string) (att io.ReadCloser, err error) +} + +type CredentialsStorer interface { + List() (userIDs []string, err error) + Add(userID, userName, apiToken, mailboxPassword string, emails []string) (*credentials.Credentials, error) + Get(userID string) (*credentials.Credentials, error) + SwitchAddressMode(userID string) error + UpdateEmails(userID string, emails []string) error + UpdateToken(userID, apiToken string) error + Logout(userID string) error + Delete(userID string) error +} diff --git a/internal/bridge/user.go b/internal/bridge/user.go new file mode 100644 index 00000000..920b4f90 --- /dev/null +++ b/internal/bridge/user.go @@ -0,0 +1,621 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package bridge + +import ( + "fmt" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/ProtonMail/proton-bridge/internal/bridge/credentials" + "github.com/ProtonMail/proton-bridge/internal/events" + "github.com/ProtonMail/proton-bridge/internal/store" + "github.com/ProtonMail/proton-bridge/pkg/listener" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/pkg/errors" + logrus "github.com/sirupsen/logrus" +) + +// ErrLoggedOutUser is sent to IMAP and SMTP if user exists, password is OK but user is logged out from bridge. +var ErrLoggedOutUser = errors.New("bridge account is logged out, use bridge to login again") + +// User is a struct on top of API client and credentials store. +type User struct { + log *logrus.Entry + panicHandler PanicHandler + listener listener.Listener + apiClient PMAPIProvider + credStorer CredentialsStorer + + imapUpdatesChannel chan interface{} + + store *store.Store + storeCache *store.Cache + storePath string + + userID string + creds *credentials.Credentials + + lock sync.RWMutex + authChannel chan *pmapi.Auth + hasAPIAuth bool + + unlockingKeyringLock sync.Mutex + wasKeyringUnlocked bool +} + +// newUser creates a new bridge user. +func newUser( + panicHandler PanicHandler, + userID string, + eventListener listener.Listener, + credStorer CredentialsStorer, + apiClient PMAPIProvider, + storeCache *store.Cache, + storeDir string, +) (u *User, err error) { + log := log.WithField("user", userID) + log.Debug("Creating or loading user") + + creds, err := credStorer.Get(userID) + if err != nil { + return nil, errors.Wrap(err, "failed to load user credentials") + } + + u = &User{ + log: log, + panicHandler: panicHandler, + listener: eventListener, + credStorer: credStorer, + apiClient: apiClient, + storeCache: storeCache, + storePath: getUserStorePath(storeDir, userID), + userID: userID, + creds: creds, + } + + return +} + +// init initialises a bridge user. This includes reloading its credentials from the credentials store +// (such as when logging out and back in, you need to reload the credentials because the new credentials will +// have the apitoken and password), authorising the user against the api, loading the user store (creating a new one +// if necessary), and setting the imap idle updates channel (used to send imap idle updates to the imap backend if +// something in the store changed). +func (u *User) init(idleUpdates chan interface{}, apiClient PMAPIProvider) (err error) { + // If this is an existing user, we still need a new api client to get a new refresh token. + // If it's a new user, doesn't matter really; this is basically a noop in this case. + u.apiClient = apiClient + + u.unlockingKeyringLock.Lock() + u.wasKeyringUnlocked = false + u.unlockingKeyringLock.Unlock() + + // Reload the user's credentials (if they log out and back in we need the new + // version with the apitoken and mailbox password). + creds, err := u.credStorer.Get(u.userID) + if err != nil { + return errors.Wrap(err, "failed to load user credentials") + } + u.creds = creds + + // Set up the auth channel on which auths from the api client are sent. + u.authChannel = make(chan *pmapi.Auth) + u.apiClient.SetAuths(u.authChannel) + u.hasAPIAuth = false + go func() { + defer u.panicHandler.HandlePanic() + u.watchAPIClientAuths() + }() + + // Try to authorise the user if they aren't already authorised. + // Note: we still allow users to set up bridge if the internet is off. + if authErr := u.authorizeIfNecessary(false); authErr != nil { + switch errors.Cause(authErr) { + case pmapi.ErrAPINotReachable, pmapi.ErrUpgradeApplication, ErrLoggedOutUser: + u.log.WithError(authErr).Warn("Could not authorize user") + default: + if logoutErr := u.logout(); logoutErr != nil { + u.log.WithError(logoutErr).Warn("Could not logout user") + } + return errors.Wrap(authErr, "failed to authorize user") + } + } + + // Logged-out user keeps store running to access offline data. + // Therefore it is necessary to close it before re-init. + if u.store != nil { + if err := u.store.Close(); err != nil { + log.WithError(err).Error("Not able to close store") + } + u.store = nil + } + store, err := store.New(u.panicHandler, u, u.apiClient, u.listener, u.storePath, u.storeCache) + if err != nil { + return errors.Wrap(err, "failed to create store") + } + u.store = store + + // Save the imap updates channel here so it can be set later when imap connects. + u.imapUpdatesChannel = idleUpdates + + return err +} + +func (u *User) SetIMAPIdleUpdateChannel() { + if u.store == nil { + return + } + + u.store.SetIMAPUpdateChannel(u.imapUpdatesChannel) +} + +// authorizeIfNecessary checks whether user is logged in and is connected to api auth channel. +// If user is not already connected to the api auth channel (for example there was no internet during start), +// it tries to connect it. See `connectToAuthChannel` for more info. +func (u *User) authorizeIfNecessary(emitEvent bool) (err error) { + // If user is connected and has an auth channel, then perfect, nothing to do here. + if u.creds.IsConnected() && u.HasAPIAuth() { + // The keyring unlock is triggered here to resolve state where apiClient + // is authenticated (we have auth token) but it was not possible to download + // and unlock the keys (internet not reachable). + return u.unlockIfNecessary() + } + + if !u.creds.IsConnected() { + err = ErrLoggedOutUser + } else if err = u.authorizeAndUnlock(); err != nil { + u.log.WithError(err).Error("Could not authorize and unlock user") + + switch errors.Cause(err) { + case pmapi.ErrUpgradeApplication: + u.listener.Emit(events.UpgradeApplicationEvent, "") + + case pmapi.ErrAPINotReachable: + u.listener.Emit(events.InternetOffEvent, "") + + default: + if errLogout := u.credStorer.Logout(u.userID); errLogout != nil { + u.log.WithField("err", errLogout).Error("Could not log user out from credentials store") + } + } + } + + if emitEvent && err != nil && + errors.Cause(err) != pmapi.ErrUpgradeApplication && + errors.Cause(err) != pmapi.ErrAPINotReachable { + u.listener.Emit(events.LogoutEvent, u.userID) + } + + return err +} + +// unlockIfNecessary will not trigger keyring unlocking if it was already successfully unlocked. +func (u *User) unlockIfNecessary() error { + u.unlockingKeyringLock.Lock() + defer u.unlockingKeyringLock.Unlock() + + if u.wasKeyringUnlocked { + return nil + } + + if _, err := u.apiClient.Unlock(u.creds.MailboxPassword); err != nil { + return errors.Wrap(err, "failed to unlock user") + } + + if err := u.apiClient.UnlockAddresses([]byte(u.creds.MailboxPassword)); err != nil { + return errors.Wrap(err, "failed to unlock user addresses") + } + + u.wasKeyringUnlocked = true + return nil +} + +// authorizeAndUnlock tries to authorize the user with the API using the the user's APIToken. +// If that succeeds, it tries to unlock the user's keys and addresses. +func (u *User) authorizeAndUnlock() (err error) { + if u.creds.APIToken == "" { + u.log.Warn("Could not connect to API auth channel, have no API token") + return nil + } + + auth, err := u.apiClient.AuthRefresh(u.creds.APIToken) + if err != nil { + return errors.Wrap(err, "failed to refresh API auth") + } + u.authChannel <- auth + + if _, err = u.apiClient.Unlock(u.creds.MailboxPassword); err != nil { + return errors.Wrap(err, "failed to unlock user") + } + + if err = u.apiClient.UnlockAddresses([]byte(u.creds.MailboxPassword)); err != nil { + return errors.Wrap(err, "failed to unlock user addresses") + } + + return nil +} + +// See `connectToAPIClientAuthChannel` for more info. +func (u *User) watchAPIClientAuths() { + for auth := range u.authChannel { + if auth != nil { + newRefreshToken := auth.UID() + ":" + auth.RefreshToken + u.updateAPIToken(newRefreshToken) + u.hasAPIAuth = true + } else if err := u.logout(); err != nil { + u.log.WithError(err).Error("Cannot logout user after receiving empty auth from API") + } + } +} + +// updateAPIToken is helper for updating the token in keychain. It's not supposed to be +// called directly from other parts of the code--only from `watchAPIClientAuths`. +func (u *User) updateAPIToken(newRefreshToken string) { + u.lock.Lock() + defer u.lock.Unlock() + + u.log.Info("Saving refresh token") + + if err := u.credStorer.UpdateToken(u.userID, newRefreshToken); err != nil { + u.log.WithError(err).Error("Cannot update refresh token in credentials store") + } else { + u.refreshFromCredentials() + } +} + +// clearStore removes the database. +func (u *User) clearStore() error { + u.log.Trace("Clearing user store") + + if u.store != nil { + if err := u.store.Remove(); err != nil { + return errors.Wrap(err, "failed to remove store") + } + } else { + u.log.Warn("Store is not initialized: cleaning up store files manually") + if err := store.RemoveStore(u.storeCache, u.storePath, u.userID); err != nil { + return errors.Wrap(err, "failed to remove store manually") + } + } + return nil +} + +// closeStore just closes the store without deleting it. +func (u *User) closeStore() error { + u.log.Trace("Closing user store") + + if u.store != nil { + if err := u.store.Close(); err != nil { + return errors.Wrap(err, "failed to close store") + } + } + + return nil +} + +// getUserStorePath returns the file path of the store database for the given userID. +func getUserStorePath(storeDir string, userID string) (path string) { + fileName := fmt.Sprintf("mailbox-%v.db", userID) + return filepath.Join(storeDir, fileName) +} + +// GetTemporaryPMAPIClient returns an authorised PMAPI client. +// Do not use! It's only for backward compatibility of old SMTP and IMAP implementations. +// After proper refactor of SMTP and IMAP remove this method. +func (u *User) GetTemporaryPMAPIClient() PMAPIProvider { + return u.apiClient +} + +// ID returns the user's userID. +func (u *User) ID() string { + return u.userID +} + +// Username returns the user's username as found in the user's credentials. +func (u *User) Username() string { + u.lock.RLock() + defer u.lock.RUnlock() + + return u.creds.Name +} + +// IsConnected returns whether user is logged in. +func (u *User) IsConnected() bool { + u.lock.RLock() + defer u.lock.RUnlock() + + return u.creds.IsConnected() +} + +// IsCombinedAddressMode returns whether user is set in combined or split mode. +// Combined mode is the default mode and is what users typically need. +// Split mode is mostly for outlook as it cannot handle sending e-mails from an +// address other than the primary one. +func (u *User) IsCombinedAddressMode() bool { + if u.store != nil { + return u.store.IsCombinedMode() + } + + return u.creds.IsCombinedAddressMode +} + +// GetPrimaryAddress returns the user's original address (which is +// not necessarily the same as the primary address, because a primary address +// might be an alias and be in position one). +func (u *User) GetPrimaryAddress() string { + u.lock.RLock() + defer u.lock.RUnlock() + + return u.creds.EmailList()[0] +} + +// GetStoreAddresses returns all addresses used by the store (so in combined mode, +// that's just the original address, but in split mode, that's all active addresses). +func (u *User) GetStoreAddresses() []string { + u.lock.RLock() + defer u.lock.RUnlock() + + if u.IsCombinedAddressMode() { + return u.creds.EmailList()[:1] + } + + return u.creds.EmailList() +} + +// getStoreAddresses returns a user's used addresses (with the original address in first place). +func (u *User) getStoreAddresses() []string { // nolint[unused] + addrInfo, err := u.store.GetAddressInfo() + if err != nil { + u.log.WithError(err).Error("Failed getting address info from store") + return nil + } + + addresses := []string{} + for _, addr := range addrInfo { + addresses = append(addresses, addr.Address) + } + + if u.IsCombinedAddressMode() { + return addresses[:1] + } + + return addresses +} + +// GetAddresses returns list of all addresses. +func (u *User) GetAddresses() []string { + u.lock.RLock() + defer u.lock.RUnlock() + + return u.creds.EmailList() +} + +// GetAddressID returns the API ID of the given address. +func (u *User) GetAddressID(address string) (id string, err error) { + u.lock.RLock() + defer u.lock.RUnlock() + + address = strings.ToLower(address) + + if u.store == nil { + err = errors.New("store is not initialised") + return + } + + return u.store.GetAddressID(address) +} + +// GetBridgePassword returns bridge password. This is not a password of the PM +// account, but generated password for local purposes to not use a PM account +// in the clients (such as Thunderbird). +func (u *User) GetBridgePassword() string { + u.lock.RLock() + defer u.lock.RUnlock() + + return u.creds.BridgePassword +} + +// CheckBridgeLogin checks whether the user is logged in and the bridge +// password is correct. +func (u *User) CheckBridgeLogin(password string) error { + if isApplicationOutdated { + u.listener.Emit(events.UpgradeApplicationEvent, "") + return pmapi.ErrUpgradeApplication + } + + u.lock.RLock() + defer u.lock.RUnlock() + + // True here because users should be notified by popup of auth failure. + if err := u.authorizeIfNecessary(true); err != nil { + u.log.WithError(err).Error("Failed to authorize user") + return err + } + + return u.creds.CheckPassword(password) +} + +// UpdateUser updates user details from API and saves to the credentials. +func (u *User) UpdateUser() error { + u.lock.Lock() + defer u.lock.Unlock() + + if err := u.authorizeIfNecessary(true); err != nil { + return errors.Wrap(err, "cannot update user") + } + + _, err := u.apiClient.UpdateUser() + if err != nil { + return err + } + + if _, err = u.apiClient.Unlock(u.creds.MailboxPassword); err != nil { + return err + } + + if err := u.apiClient.UnlockAddresses([]byte(u.creds.MailboxPassword)); err != nil { + return err + } + + emails := u.apiClient.Addresses().ActiveEmails() + if err := u.credStorer.UpdateEmails(u.userID, emails); err != nil { + return err + } + + u.refreshFromCredentials() + + return nil +} + +// SwitchAddressMode changes mode from combined to split and vice versa. The mode to switch to is determined by the +// state of the user's credentials in the credentials store. See `IsCombinedAddressMode` for more details. +func (u *User) SwitchAddressMode() (err error) { + u.log.Trace("Switching user address mode") + + u.lock.Lock() + defer u.lock.Unlock() + u.closeAllConnections() + + if u.store == nil { + err = errors.New("store is not initialised") + return + } + + newAddressModeState := !u.IsCombinedAddressMode() + + if err = u.store.UseCombinedMode(newAddressModeState); err != nil { + u.log.WithError(err).Error("Could not switch store address mode") + return + } + + if u.creds.IsCombinedAddressMode != newAddressModeState { + if err = u.credStorer.SwitchAddressMode(u.userID); err != nil { + u.log.WithError(err).Error("Could not switch credentials store address mode") + return + } + } + + u.refreshFromCredentials() + + return err +} + +// logout is the same as Logout, but for internal purposes (logged out from +// the server) which emits LogoutEvent to notify other parts of the Bridge. +func (u *User) logout() error { + u.lock.Lock() + wasConnected := u.creds.IsConnected() + u.lock.Unlock() + err := u.Logout() + if wasConnected { + u.listener.Emit(events.LogoutEvent, u.userID) + u.listener.Emit(events.UserRefreshEvent, u.userID) + } + return err +} + +// Logout logs out the user from pmapi, the credentials store, the mail store, and tries to remove as much +// sensitive data as possible. +func (u *User) Logout() (err error) { + u.lock.Lock() + defer u.lock.Unlock() + + u.log.Debug("Logging out user") + + if !u.creds.IsConnected() { + return + } + + u.unlockingKeyringLock.Lock() + u.wasKeyringUnlocked = false + u.unlockingKeyringLock.Unlock() + + if err = u.apiClient.Logout(); err != nil { + u.log.WithError(err).Warn("Could not log user out from API client") + } + u.apiClient.SetAuths(nil) + + // Logout needs to stop auth channel so when user logs back in + // it can register again with new client. + // Note: be careful to not close channel twice. + if u.authChannel != nil { + close(u.authChannel) + u.authChannel = nil + } + + if err = u.credStorer.Logout(u.userID); err != nil { + u.log.WithError(err).Warn("Could not log user out from credentials store") + + if err = u.credStorer.Delete(u.userID); err != nil { + u.log.WithError(err).Error("Could not delete user from credentials store") + } + } + + u.refreshFromCredentials() + + // Do not close whole store, just event loop. Some information might be needed offline (e.g. addressID) + u.closeEventLoop() + + u.closeAllConnections() + runtime.GC() + + return err +} + +func (u *User) refreshFromCredentials() { + if credentials, err := u.credStorer.Get(u.userID); err != nil { + log.Error("Cannot update credentials: ", err) + } else { + u.creds = credentials + } +} + +func (u *User) closeEventLoop() { + if u.store == nil { + return + } + + u.store.CloseEventLoop() +} + +// closeAllConnections calls CloseConnection for all users addresses. +func (u *User) closeAllConnections() { + for _, address := range u.creds.EmailList() { + u.CloseConnection(address) + } + + if u.store != nil { + u.store.SetIMAPUpdateChannel(nil) + } +} + +// CloseConnection emits closeConnection event on `address` which should close all active connection. +func (u *User) CloseConnection(address string) { + u.listener.Emit(events.CloseConnectionEvent, address) +} + +func (u *User) GetStore() *store.Store { + return u.store +} + +func (u *User) HasAPIAuth() bool { + return u.hasAPIAuth +} diff --git a/internal/bridge/user_credentials_test.go b/internal/bridge/user_credentials_test.go new file mode 100644 index 00000000..20268f11 --- /dev/null +++ b/internal/bridge/user_credentials_test.go @@ -0,0 +1,209 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package bridge + +import ( + "testing" + + "github.com/ProtonMail/proton-bridge/internal/events" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + gomock "github.com/golang/mock/gomock" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" +) + +func TestUpdateUser(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + user := testNewUser(m) + defer cleanUpUserData(user) + + m.pmapiClient.EXPECT().Unlock("pass").Return(nil, nil) + m.pmapiClient.EXPECT().UnlockAddresses([]byte("pass")).Return(nil) + + m.pmapiClient.EXPECT().UpdateUser().Return(nil, nil) + m.pmapiClient.EXPECT().Unlock("pass").Return(nil, nil) + m.pmapiClient.EXPECT().UnlockAddresses([]byte(testCredentials.MailboxPassword)).Return(nil) + m.pmapiClient.EXPECT().Addresses().Return([]*pmapi.Address{testPMAPIAddress}) + + m.credentialsStore.EXPECT().UpdateEmails("user", []string{testPMAPIAddress.Email}) + m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil) + + assert.NoError(t, user.UpdateUser()) + + waitForEvents() +} + +func TestUserSwitchAddressMode(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + user := testNewUser(m) + defer cleanUpUserData(user) + + assert.True(t, user.store.IsCombinedMode()) + assert.True(t, user.creds.IsCombinedAddressMode) + assert.True(t, user.IsCombinedAddressMode()) + waitForEvents() + + gomock.InOrder( + m.eventListener.EXPECT().Emit(events.CloseConnectionEvent, "user@pm.me"), + m.pmapiClient.EXPECT().ListLabels().Return([]*pmapi.Label{}, nil), + m.pmapiClient.EXPECT().CountMessages("").Return([]*pmapi.MessagesCount{}, nil), + m.pmapiClient.EXPECT().Addresses().Return([]*pmapi.Address{testPMAPIAddress}), + + m.credentialsStore.EXPECT().SwitchAddressMode("user").Return(nil), + m.credentialsStore.EXPECT().Get("user").Return(testCredentialsSplit, nil), + ) + m.pmapiClient.EXPECT().ListMessages(gomock.Any()).Return([]*pmapi.Message{}, 0, nil) + + assert.NoError(t, user.SwitchAddressMode()) + assert.False(t, user.store.IsCombinedMode()) + assert.False(t, user.creds.IsCombinedAddressMode) + assert.False(t, user.IsCombinedAddressMode()) + + gomock.InOrder( + m.eventListener.EXPECT().Emit(events.CloseConnectionEvent, "users@pm.me"), + m.eventListener.EXPECT().Emit(events.CloseConnectionEvent, "anotheruser@pm.me"), + m.eventListener.EXPECT().Emit(events.CloseConnectionEvent, "alsouser@pm.me"), + m.pmapiClient.EXPECT().ListLabels().Return([]*pmapi.Label{}, nil), + m.pmapiClient.EXPECT().CountMessages("").Return([]*pmapi.MessagesCount{}, nil), + m.pmapiClient.EXPECT().Addresses().Return([]*pmapi.Address{testPMAPIAddress}), + + m.credentialsStore.EXPECT().SwitchAddressMode("user").Return(nil), + m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil), + ) + m.pmapiClient.EXPECT().ListMessages(gomock.Any()).Return([]*pmapi.Message{}, 0, nil).AnyTimes() + + assert.NoError(t, user.SwitchAddressMode()) + assert.True(t, user.store.IsCombinedMode()) + assert.True(t, user.creds.IsCombinedAddressMode) + assert.True(t, user.IsCombinedAddressMode()) + + waitForEvents() +} + +func TestLogoutUser(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + user := testNewUserForLogout(m) + defer cleanUpUserData(user) + + m.pmapiClient.EXPECT().Logout().Return(nil) + m.credentialsStore.EXPECT().Logout("user").Return(nil) + m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil) + m.eventListener.EXPECT().Emit(events.CloseConnectionEvent, "user@pm.me") + + err := user.Logout() + + waitForEvents() + + assert.NoError(t, err) +} + +func TestLogoutUserFailsLogout(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + user := testNewUserForLogout(m) + defer cleanUpUserData(user) + + m.pmapiClient.EXPECT().Logout().Return(nil) + m.credentialsStore.EXPECT().Logout("user").Return(errors.New("logout failed")) + m.credentialsStore.EXPECT().Delete("user").Return(nil) + m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil) + m.eventListener.EXPECT().Emit(events.CloseConnectionEvent, "user@pm.me") + + err := user.Logout() + waitForEvents() + assert.NoError(t, err) +} + +func TestCheckBridgeLogin(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + user := testNewUser(m) + defer cleanUpUserData(user) + + m.pmapiClient.EXPECT().Unlock("pass").Return(nil, nil) + m.pmapiClient.EXPECT().UnlockAddresses([]byte("pass")).Return(nil) + + err := user.CheckBridgeLogin(testCredentials.BridgePassword) + + waitForEvents() + + assert.NoError(t, err) +} + +func TestCheckBridgeLoginUpgradeApplication(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + user := testNewUser(m) + defer cleanUpUserData(user) + + m.eventListener.EXPECT().Emit(events.UpgradeApplicationEvent, "") + + isApplicationOutdated = true + err := user.CheckBridgeLogin("any-pass") + waitForEvents() + isApplicationOutdated = false + + assert.Equal(t, pmapi.ErrUpgradeApplication, err) +} + +func TestCheckBridgeLoginLoggedOut(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + m.credentialsStore.EXPECT().Get("user").Return(testCredentialsDisconnected, nil) + user, _ := newUser(m.PanicHandler, "user", m.eventListener, m.credentialsStore, m.pmapiClient, m.storeCache, "/tmp") + m.pmapiClient.EXPECT().ListLabels().Return(nil, errors.New("ErrUnauthorized")) + m.pmapiClient.EXPECT().Addresses().Return(nil) + m.pmapiClient.EXPECT().SetAuths(gomock.Any()) + + m.credentialsStore.EXPECT().Get("user").Return(testCredentialsDisconnected, nil) + _ = user.init(nil, m.pmapiClient) + + defer cleanUpUserData(user) + + m.eventListener.EXPECT().Emit(events.LogoutEvent, "user") + + err := user.CheckBridgeLogin(testCredentialsDisconnected.BridgePassword) + waitForEvents() + + assert.Equal(t, "bridge account is logged out, use bridge to login again", err.Error()) +} + +func TestCheckBridgeLoginBadPassword(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + user := testNewUser(m) + defer cleanUpUserData(user) + + m.pmapiClient.EXPECT().Unlock("pass").Return(nil, nil) + m.pmapiClient.EXPECT().UnlockAddresses([]byte("pass")).Return(nil) + + err := user.CheckBridgeLogin("wrong!") + waitForEvents() + assert.Equal(t, "backend/credentials: incorrect password", err.Error()) +} diff --git a/internal/bridge/user_new_test.go b/internal/bridge/user_new_test.go new file mode 100644 index 00000000..0c038a2e --- /dev/null +++ b/internal/bridge/user_new_test.go @@ -0,0 +1,188 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package bridge + +import ( + "errors" + "testing" + + "github.com/ProtonMail/proton-bridge/internal/events" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + gomock "github.com/golang/mock/gomock" + a "github.com/stretchr/testify/assert" +) + +func TestNewUserNoCredentialsStore(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + m.credentialsStore.EXPECT().Get("user").Return(nil, errors.New("fail")) + + _, err := newUser(m.PanicHandler, "user", m.eventListener, m.credentialsStore, m.pmapiClient, m.storeCache, "/tmp") + a.Error(t, err) +} + +func TestNewUserBridgeOutdated(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil).Times(2) + m.credentialsStore.EXPECT().Logout("user").Return(nil).AnyTimes() + m.pmapiClient.EXPECT().AuthRefresh("token").Return(nil, pmapi.ErrUpgradeApplication).AnyTimes() + m.pmapiClient.EXPECT().SetAuths(gomock.Any()) + m.eventListener.EXPECT().Emit(events.UpgradeApplicationEvent, "").AnyTimes() + m.pmapiClient.EXPECT().ListLabels().Return(nil, pmapi.ErrUpgradeApplication) + m.pmapiClient.EXPECT().Addresses().Return(nil) + + checkNewUser(m) +} + +func TestNewUserNoInternetConnection(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil).Times(2) + m.pmapiClient.EXPECT().AuthRefresh("token").Return(nil, pmapi.ErrAPINotReachable).AnyTimes() + m.pmapiClient.EXPECT().SetAuths(gomock.Any()) + m.eventListener.EXPECT().Emit(events.InternetOffEvent, "").AnyTimes() + + m.pmapiClient.EXPECT().Addresses().Return(nil) + m.pmapiClient.EXPECT().ListLabels().Return(nil, pmapi.ErrAPINotReachable) + m.pmapiClient.EXPECT().GetEvent("").Return(nil, pmapi.ErrAPINotReachable).AnyTimes() + + checkNewUser(m) +} + +func TestNewUserAuthRefreshFails(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil).Times(2) + m.credentialsStore.EXPECT().Logout("user").Return(nil) + m.pmapiClient.EXPECT().AuthRefresh("token").Return(nil, errors.New("bad token")).AnyTimes() + m.pmapiClient.EXPECT().SetAuths(gomock.Any()) + + m.eventListener.EXPECT().Emit(events.LogoutEvent, "user") + m.eventListener.EXPECT().Emit(events.UserRefreshEvent, "user") + m.pmapiClient.EXPECT().Logout().Return(nil) + m.pmapiClient.EXPECT().SetAuths(nil) + m.credentialsStore.EXPECT().Logout("user").Return(nil) + m.credentialsStore.EXPECT().Get("user").Return(testCredentialsDisconnected, nil) + m.eventListener.EXPECT().Emit(events.CloseConnectionEvent, "user@pm.me") + + checkNewUserDisconnected(m) +} + +func TestNewUserUnlockFails(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil).Times(2) + m.credentialsStore.EXPECT().UpdateToken("user", ":reftok").Return(nil) + m.credentialsStore.EXPECT().Logout("user").Return(nil) + + m.pmapiClient.EXPECT().SetAuths(gomock.Any()) + m.pmapiClient.EXPECT().AuthRefresh("token").Return(testAuthRefresh, nil) + m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil) + m.pmapiClient.EXPECT().Unlock("pass").Return(nil, errors.New("bad password")) + + m.eventListener.EXPECT().Emit(events.LogoutEvent, "user") + m.eventListener.EXPECT().Emit(events.UserRefreshEvent, "user") + m.pmapiClient.EXPECT().Logout().Return(nil) + m.pmapiClient.EXPECT().SetAuths(nil) + m.credentialsStore.EXPECT().Logout("user").Return(nil) + m.credentialsStore.EXPECT().Get("user").Return(testCredentialsDisconnected, nil) + m.eventListener.EXPECT().Emit(events.CloseConnectionEvent, "user@pm.me") + + checkNewUserDisconnected(m) +} + +func TestNewUserUnlockAddressesFails(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil).Times(2) + m.credentialsStore.EXPECT().UpdateToken("user", ":reftok").Return(nil) + m.credentialsStore.EXPECT().Logout("user").Return(nil) + + m.pmapiClient.EXPECT().SetAuths(gomock.Any()) + m.pmapiClient.EXPECT().AuthRefresh("token").Return(testAuthRefresh, nil) + m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil) + m.pmapiClient.EXPECT().Unlock("pass").Return(nil, nil) + m.pmapiClient.EXPECT().UnlockAddresses([]byte("pass")).Return(errors.New("bad password")) + + m.eventListener.EXPECT().Emit(events.LogoutEvent, "user") + m.eventListener.EXPECT().Emit(events.UserRefreshEvent, "user") + m.pmapiClient.EXPECT().Logout().Return(nil) + m.pmapiClient.EXPECT().SetAuths(nil) + m.credentialsStore.EXPECT().Logout("user").Return(nil) + m.credentialsStore.EXPECT().Get("user").Return(testCredentialsDisconnected, nil) + m.eventListener.EXPECT().Emit(events.CloseConnectionEvent, "user@pm.me") + + checkNewUserDisconnected(m) +} + +func TestNewUser(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil).Times(2) + m.credentialsStore.EXPECT().UpdateToken("user", ":reftok").Return(nil) + + m.pmapiClient.EXPECT().SetAuths(gomock.Any()) + m.pmapiClient.EXPECT().AuthRefresh("token").Return(testAuthRefresh, nil) + m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil) + m.pmapiClient.EXPECT().Unlock("pass").Return(nil, nil) + m.pmapiClient.EXPECT().UnlockAddresses([]byte("pass")).Return(nil) + + m.pmapiClient.EXPECT().Addresses().Return([]*pmapi.Address{testPMAPIAddress}) + m.pmapiClient.EXPECT().ListLabels().Return([]*pmapi.Label{}, nil) + m.pmapiClient.EXPECT().CountMessages("").Return([]*pmapi.MessagesCount{}, nil) + + m.pmapiClient.EXPECT().GetEvent("").Return(testPMAPIEvent, nil) + m.pmapiClient.EXPECT().ListMessages(gomock.Any()).Return([]*pmapi.Message{}, 0, nil) + m.pmapiClient.EXPECT().GetEvent(testPMAPIEvent.EventID).Return(testPMAPIEvent, nil) + + checkNewUser(m) +} + +func checkNewUser(m mocks) { + user, _ := newUser(m.PanicHandler, "user", m.eventListener, m.credentialsStore, m.pmapiClient, m.storeCache, "/tmp") + defer cleanUpUserData(user) + + _ = user.init(nil, m.pmapiClient) + + waitForEvents() + + a.Equal(m.t, testCredentials, user.creds) +} + +func checkNewUserDisconnected(m mocks) { + user, _ := newUser(m.PanicHandler, "user", m.eventListener, m.credentialsStore, m.pmapiClient, m.storeCache, "/tmp") + defer cleanUpUserData(user) + + _ = user.init(nil, m.pmapiClient) + + waitForEvents() + + a.Equal(m.t, testCredentialsDisconnected, user.creds) +} + +func _TestUserEventRefreshUpdatesAddresses(t *testing.T) { // nolint[funlen] + a.Fail(t, "not implemented") +} diff --git a/internal/bridge/user_test.go b/internal/bridge/user_test.go new file mode 100644 index 00000000..d13dcfeb --- /dev/null +++ b/internal/bridge/user_test.go @@ -0,0 +1,113 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package bridge + +import ( + "testing" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + gomock "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testNewUser(m mocks) *User { + m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil).Times(2) + m.credentialsStore.EXPECT().UpdateToken("user", ":reftok").Return(nil) + + m.pmapiClient.EXPECT().SetAuths(gomock.Any()) + m.pmapiClient.EXPECT().AuthRefresh("token").Return(testAuthRefresh, nil) + m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil) + m.pmapiClient.EXPECT().Unlock("pass").Return(nil, nil) + m.pmapiClient.EXPECT().UnlockAddresses([]byte("pass")).Return(nil) + + // Expectations for initial sync (when loading existing user from credentials store). + m.pmapiClient.EXPECT().ListLabels().Return([]*pmapi.Label{}, nil) + m.pmapiClient.EXPECT().Addresses().Return([]*pmapi.Address{testPMAPIAddress}) + m.pmapiClient.EXPECT().CountMessages("").Return([]*pmapi.MessagesCount{}, nil) + m.pmapiClient.EXPECT().GetEvent("").Return(testPMAPIEvent, nil).AnyTimes() + m.pmapiClient.EXPECT().ListMessages(gomock.Any()).Return([]*pmapi.Message{}, 0, nil) + m.pmapiClient.EXPECT().GetEvent(testPMAPIEvent.EventID).Return(testPMAPIEvent, nil).AnyTimes() + + user, err := newUser(m.PanicHandler, "user", m.eventListener, m.credentialsStore, m.pmapiClient, m.storeCache, "/tmp") + assert.NoError(m.t, err) + + err = user.init(nil, m.pmapiClient) + assert.NoError(m.t, err) + + return user +} + +func testNewUserForLogout(m mocks) *User { + m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil).Times(2) + m.credentialsStore.EXPECT().UpdateToken("user", ":reftok").Return(nil) + + m.pmapiClient.EXPECT().SetAuths(gomock.Any()) + m.pmapiClient.EXPECT().AuthRefresh("token").Return(testAuthRefresh, nil) + m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil) + m.pmapiClient.EXPECT().Unlock("pass").Return(nil, nil) + m.pmapiClient.EXPECT().UnlockAddresses([]byte("pass")).Return(nil) + + // These may or may not be hit depending on how fast the log out happens. + m.pmapiClient.EXPECT().SetAuths(nil).AnyTimes() + m.pmapiClient.EXPECT().ListLabels().Return([]*pmapi.Label{}, nil).AnyTimes() + m.pmapiClient.EXPECT().Addresses().Return([]*pmapi.Address{testPMAPIAddress}).AnyTimes() + m.pmapiClient.EXPECT().CountMessages("").Return([]*pmapi.MessagesCount{}, nil) + m.pmapiClient.EXPECT().GetEvent("").Return(testPMAPIEvent, nil).AnyTimes() + m.pmapiClient.EXPECT().ListMessages(gomock.Any()).Return([]*pmapi.Message{}, 0, nil).AnyTimes() + m.pmapiClient.EXPECT().GetEvent(testPMAPIEvent.EventID).Return(testPMAPIEvent, nil).AnyTimes() + + user, err := newUser(m.PanicHandler, "user", m.eventListener, m.credentialsStore, m.pmapiClient, m.storeCache, "/tmp") + assert.NoError(m.t, err) + + err = user.init(nil, m.pmapiClient) + assert.NoError(m.t, err) + + return user +} + +func cleanUpUserData(u *User) { + _ = u.clearStore() +} + +func _TestNeverLongStorePath(t *testing.T) { // nolint[unused] + assert.Fail(t, "not implemented") +} + +func TestClearStoreWithStore(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + user := testNewUserForLogout(m) + defer cleanUpUserData(user) + + require.Nil(t, user.store.Close()) + user.store = nil + assert.Nil(t, user.clearStore()) +} + +func TestClearStoreWithoutStore(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + user := testNewUserForLogout(m) + defer cleanUpUserData(user) + + assert.NotNil(t, user.store) + assert.Nil(t, user.clearStore()) +} diff --git a/internal/bridge/useragent.go b/internal/bridge/useragent.go new file mode 100644 index 00000000..3ebd3a31 --- /dev/null +++ b/internal/bridge/useragent.go @@ -0,0 +1,41 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package bridge + +import ( + "fmt" + "runtime" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" +) + +// UpdateCurrentUserAgent updates user agent on pmapi so each request has this +// information in headers for statistic purposes. +func UpdateCurrentUserAgent(bridgeVersion, os, clientName, clientVersion string) { + if os == "" { + os = runtime.GOOS + } + mailClient := "unknown client" + if clientName != "" { + mailClient = clientName + if clientVersion != "" { + mailClient += "/" + clientVersion + } + } + pmapi.CurrentUserAgent = fmt.Sprintf("Bridge/%s (%s; %s)", bridgeVersion, os, mailClient) +} diff --git a/internal/bridge/useragent_test.go b/internal/bridge/useragent_test.go new file mode 100644 index 00000000..560a93af --- /dev/null +++ b/internal/bridge/useragent_test.go @@ -0,0 +1,51 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package bridge + +import ( + "runtime" + "testing" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/stretchr/testify/assert" +) + +func TestUpdateCurrentUserAgentGOOS(t *testing.T) { + UpdateCurrentUserAgent("ver", "", "", "") + assert.Equal(t, "Bridge/ver ("+runtime.GOOS+"; unknown client)", pmapi.CurrentUserAgent) +} + +func TestUpdateCurrentUserAgentOS(t *testing.T) { + UpdateCurrentUserAgent("ver", "os", "", "") + assert.Equal(t, "Bridge/ver (os; unknown client)", pmapi.CurrentUserAgent) +} + +func TestUpdateCurrentUserAgentClientVer(t *testing.T) { + UpdateCurrentUserAgent("ver", "os", "", "cver") + assert.Equal(t, "Bridge/ver (os; unknown client)", pmapi.CurrentUserAgent) +} + +func TestUpdateCurrentUserAgentClientName(t *testing.T) { + UpdateCurrentUserAgent("ver", "os", "mail", "") + assert.Equal(t, "Bridge/ver (os; mail)", pmapi.CurrentUserAgent) +} + +func TestUpdateCurrentUserAgentClientNameAndVersion(t *testing.T) { + UpdateCurrentUserAgent("ver", "os", "mail", "cver") + assert.Equal(t, "Bridge/ver (os; mail/cver)", pmapi.CurrentUserAgent) +} diff --git a/internal/events/events.go b/internal/events/events.go new file mode 100644 index 00000000..773b61a6 --- /dev/null +++ b/internal/events/events.go @@ -0,0 +1,53 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Package events provides names of events used by the event listener in bridge. +package events + +import ( + "time" + + "github.com/ProtonMail/proton-bridge/pkg/listener" +) + +// Constants of events used by the event listener in bridge. +const ( + ErrorEvent = "error" + CloseConnectionEvent = "closeConnection" + LogoutEvent = "logout" + AddressChangedEvent = "addressChanged" + AddressChangedLogoutEvent = "addressChangedLogout" + UserRefreshEvent = "userRefresh" + RestartBridgeEvent = "restartBridge" + InternetOffEvent = "internetOff" + InternetOnEvent = "internetOn" + SecondInstanceEvent = "secondInstance" + OutgoingNoEncEvent = "outgoingNoEncryption" + NoActiveKeyForRecipientEvent = "noActiveKeyForRecipient" + UpgradeApplicationEvent = "upgradeApplication" + TLSCertIssue = "tlsCertPinningIssue" + + // LogoutEventTimeout is the minimum time to permit between logout events being sent. + LogoutEventTimeout = 3 * time.Minute +) + +// SetupEvents specific to event type and data. +func SetupEvents(listener listener.Listener) { + listener.SetLimit(LogoutEvent, LogoutEventTimeout) + listener.SetBuffer(TLSCertIssue) + listener.SetBuffer(ErrorEvent) +} diff --git a/internal/frontend/autoconfig/applemail.go b/internal/frontend/autoconfig/applemail.go new file mode 100644 index 00000000..cbc05fc4 --- /dev/null +++ b/internal/frontend/autoconfig/applemail.go @@ -0,0 +1,108 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build darwin + +package autoconfig + +import ( + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/ProtonMail/proton-bridge/internal/bridge" + "github.com/ProtonMail/proton-bridge/internal/frontend/types" + mobileconfig "github.com/ProtonMail/go-apple-mobileconfig" +) + +func init() { //nolint[gochecknoinit] + available = append(available, &appleMail{}) +} + +type appleMail struct{} + +func (c *appleMail) Name() string { + return "Apple Mail" +} + +func (c *appleMail) Configure(imapPort, smtpPort int, imapSSL, smtpSSL bool, user types.BridgeUser, addressIndex int) error { //nolint[funlen] + var addresses string + var displayName string + + if user.IsCombinedAddressMode() { + displayName = user.GetPrimaryAddress() + addresses = strings.Join(user.GetAddresses(), ",") + } else { + for idx, address := range user.GetAddresses() { + if idx == addressIndex { + displayName = address + break + } + } + addresses = displayName + } + + timestamp := strconv.FormatInt(time.Now().Unix(), 10) + + mc := &mobileconfig.Config{ + EmailAddress: addresses, + DisplayName: displayName, + Identifier: "protonmail " + displayName + timestamp, + Imap: &mobileconfig.Imap{ + Hostname: bridge.Host, + Port: imapPort, + Tls: imapSSL, + Username: displayName, + Password: user.GetBridgePassword(), + }, + Smtp: &mobileconfig.Smtp{ + Hostname: bridge.Host, + Port: smtpPort, + Tls: smtpSSL, + Username: displayName, + }, + } + + dir, err := ioutil.TempDir("", "protonmail-autoconfig") + if err != nil { + return err + } + + // Make sure the temporary file is deleted. + go (func() { + <-time.After(10 * time.Minute) + _ = os.RemoveAll(dir) + })() + + // Make sure the file is only readable for the current user. + f, err := os.OpenFile(filepath.Join(dir, "protonmail.mobileconfig"), os.O_RDWR|os.O_CREATE, 0600) + if err != nil { + return err + } + + if err := mc.WriteTo(f); err != nil { + _ = f.Close() + return err + } + _ = f.Close() + + return exec.Command("open", f.Name()).Run() // nolint[gosec] +} diff --git a/internal/frontend/autoconfig/autoconfig.go b/internal/frontend/autoconfig/autoconfig.go new file mode 100644 index 00000000..81a08ba9 --- /dev/null +++ b/internal/frontend/autoconfig/autoconfig.go @@ -0,0 +1,33 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Package autoconfig provides automatic config of IMAP and SMTP. +// For now only for Apple Mail. +package autoconfig + +import "github.com/ProtonMail/proton-bridge/internal/frontend/types" + +type AutoConfig interface { + Name() string + Configure(imapPort int, smtpPort int, imapSSl, smtpSSL bool, user types.BridgeUser, addressIndex int) error +} + +var available []AutoConfig //nolint[gochecknoglobals] + +func Available() []AutoConfig { + return available +} diff --git a/internal/frontend/cli/account_utils.go b/internal/frontend/cli/account_utils.go new file mode 100644 index 00000000..c2fdaec5 --- /dev/null +++ b/internal/frontend/cli/account_utils.go @@ -0,0 +1,100 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package cli + +import ( + "fmt" + "strconv" + "strings" + + "github.com/ProtonMail/proton-bridge/internal/frontend/types" + "github.com/abiosoft/ishell" +) + +// completeUsernames is a helper to complete usernames as the user types. +func (f *frontendCLI) completeUsernames(args []string) (usernames []string) { + if len(args) > 1 { + return + } + arg := "" + if len(args) == 1 { + arg = args[0] + } + for _, user := range f.bridge.GetUsers() { + if strings.HasPrefix(strings.ToLower(user.Username()), strings.ToLower(arg)) { + usernames = append(usernames, user.Username()) + } + } + return +} + +// noAccountWrapper is a decorator for functions which need any account to be properly functional. +func (f *frontendCLI) noAccountWrapper(callback func(*ishell.Context)) func(*ishell.Context) { + return func(c *ishell.Context) { + users := f.bridge.GetUsers() + if len(users) == 0 { + f.Println("No active accounts. Please add account to continue.") + } else { + callback(c) + } + } +} + +func (f *frontendCLI) askUserByIndexOrName(c *ishell.Context) types.BridgeUser { + user := f.getUserByIndexOrName("") + if user != nil { + return user + } + + numberOfAccounts := len(f.bridge.GetUsers()) + indexRange := fmt.Sprintf("number between 0 and %d", numberOfAccounts-1) + if len(c.Args) == 0 { + f.Printf("Please choose %s or username.\n", indexRange) + return nil + } + arg := c.Args[0] + user = f.getUserByIndexOrName(arg) + if user == nil { + f.Printf("Wrong input '%s'. Choose %s or username.\n", bold(arg), indexRange) + return nil + } + return user +} + +func (f *frontendCLI) getUserByIndexOrName(arg string) types.BridgeUser { + users := f.bridge.GetUsers() + numberOfAccounts := len(users) + if numberOfAccounts == 0 { + return nil + } + if numberOfAccounts == 1 { + return users[0] + } + if index, err := strconv.Atoi(arg); err == nil { + if index < 0 || index >= numberOfAccounts { + return nil + } + return users[index] + } + for _, user := range users { + if user.Username() == arg { + return user + } + } + return nil +} diff --git a/internal/frontend/cli/accounts.go b/internal/frontend/cli/accounts.go new file mode 100644 index 00000000..213f11ec --- /dev/null +++ b/internal/frontend/cli/accounts.go @@ -0,0 +1,219 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package cli + +import ( + "strings" + + "github.com/ProtonMail/proton-bridge/internal/bridge" + "github.com/ProtonMail/proton-bridge/internal/frontend/types" + "github.com/ProtonMail/proton-bridge/internal/preferences" + "github.com/abiosoft/ishell" +) + +func (f *frontendCLI) listAccounts(c *ishell.Context) { + spacing := "%-2d: %-20s (%-15s, %-15s)\n" + f.Printf(bold(strings.Replace(spacing, "d", "s", -1)), "#", "account", "status", "address mode") + for idx, user := range f.bridge.GetUsers() { + connected := "disconnected" + if user.IsConnected() { + connected = "connected" + } + mode := "split" + if user.IsCombinedAddressMode() { + mode = "combined" + } + f.Printf(spacing, idx, user.Username(), connected, mode) + } + f.Println() +} + +func (f *frontendCLI) showAccountInfo(c *ishell.Context) { + user := f.askUserByIndexOrName(c) + if user == nil { + return + } + + if !user.IsConnected() { + f.Printf("Please login to %s to get email client configuration.\n", bold(user.Username())) + return + } + + if user.IsCombinedAddressMode() { + f.showAccountAddressInfo(user, user.GetPrimaryAddress()) + } else { + for _, address := range user.GetAddresses() { + f.showAccountAddressInfo(user, address) + } + } +} + +func (f *frontendCLI) showAccountAddressInfo(user types.BridgeUser, address string) { + smtpSecurity := "STARTTLS" + if f.preferences.GetBool(preferences.SMTPSSLKey) { + smtpSecurity = "SSL" + } + f.Println(bold("Configuration for " + address)) + f.Printf("IMAP Settings\nAddress: %s\nIMAP port: %d\nUsername: %s\nPassword: %s\nSecurity: %s\n", + bridge.Host, + f.preferences.GetInt(preferences.IMAPPortKey), + address, + user.GetBridgePassword(), + "STARTTLS", + ) + f.Println("") + f.Printf("SMTP Settings\nAddress: %s\nIMAP port: %d\nUsername: %s\nPassword: %s\nSecurity: %s\n", + bridge.Host, + f.preferences.GetInt(preferences.SMTPPortKey), + address, + user.GetBridgePassword(), + smtpSecurity, + ) + f.Println("") +} + +func (f *frontendCLI) loginAccount(c *ishell.Context) { // nolint[funlen] + f.ShowPrompt(false) + defer f.ShowPrompt(true) + + loginName := "" + if len(c.Args) > 0 { + user := f.getUserByIndexOrName(c.Args[0]) + if user != nil { + loginName = user.GetPrimaryAddress() + } + } + + if loginName == "" { + loginName = f.readStringInAttempts("Username", c.ReadLine, isNotEmpty) + if loginName == "" { + return + } + } else { + f.Println("Username:", loginName) + } + + password := f.readStringInAttempts("Password", c.ReadPassword, isNotEmpty) + if password == "" { + return + } + + f.Println("Authenticating ... ") + client, auth, err := f.bridge.Login(loginName, password) + if err != nil { + f.processAPIError(err) + return + } + + if auth.HasTwoFactor() { + twoFactor := f.readStringInAttempts("Two factor code", c.ReadLine, isNotEmpty) + if twoFactor == "" { + return + } + + _, err = client.Auth2FA(twoFactor, auth) + if err != nil { + f.processAPIError(err) + return + } + } + + mailboxPassword := password + if auth.HasMailboxPassword() { + mailboxPassword = f.readStringInAttempts("Mailbox password", c.ReadPassword, isNotEmpty) + } + if mailboxPassword == "" { + return + } + + f.Println("Adding account ...") + user, err := f.bridge.FinishLogin(client, auth, mailboxPassword) + if err != nil { + log.WithField("username", loginName).WithError(err).Error("Login was unsuccessful") + f.Println("Adding account was unsuccessful:", err) + return + } + + f.Printf("Account %s was added successfully.\n", bold(user.Username())) +} + +func (f *frontendCLI) logoutAccount(c *ishell.Context) { + f.ShowPrompt(false) + defer f.ShowPrompt(true) + + user := f.askUserByIndexOrName(c) + if user == nil { + return + } + if f.yesNoQuestion("Are you sure you want to logout account " + bold(user.Username())) { + if err := user.Logout(); err != nil { + f.printAndLogError("Logging out failed: ", err) + } + } +} + +func (f *frontendCLI) deleteAccount(c *ishell.Context) { + f.ShowPrompt(false) + defer f.ShowPrompt(true) + + user := f.askUserByIndexOrName(c) + if user == nil { + return + } + if f.yesNoQuestion("Are you sure you want to " + bold("remove account "+user.Username())) { + clearCache := f.yesNoQuestion("Do you want to remove cache for this account") + if err := f.bridge.DeleteUser(user.ID(), clearCache); err != nil { + f.printAndLogError("Cannot delete account: ", err) + return + } + } +} + +func (f *frontendCLI) deleteAccounts(c *ishell.Context) { + f.ShowPrompt(false) + defer f.ShowPrompt(true) + + if !f.yesNoQuestion("Do you really want remove all accounts") { + return + } + for _, user := range f.bridge.GetUsers() { + if err := f.bridge.DeleteUser(user.ID(), false); err != nil { + f.printAndLogError("Cannot delete account ", user.Username(), ": ", err) + } + } + c.Println("Keychain cleared") +} + +func (f *frontendCLI) changeMode(c *ishell.Context) { + user := f.askUserByIndexOrName(c) + if user == nil { + return + } + + newMode := "combined mode" + if user.IsCombinedAddressMode() { + newMode = "split mode" + } + if !f.yesNoQuestion("Are you sure you want to change the mode for account " + bold(user.Username()) + " to " + bold(newMode)) { + return + } + if err := user.SwitchAddressMode(); err != nil { + f.printAndLogError("Cannot switch address mode:", err) + } + f.Printf("Address mode for account %s changed to %s\n", user.Username(), newMode) +} diff --git a/internal/frontend/cli/frontend.go b/internal/frontend/cli/frontend.go new file mode 100644 index 00000000..d0e6c61f --- /dev/null +++ b/internal/frontend/cli/frontend.go @@ -0,0 +1,264 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Package cli provides CLI interface of the Bridge. +package cli + +import ( + "github.com/ProtonMail/proton-bridge/internal/events" + "github.com/ProtonMail/proton-bridge/internal/frontend/types" + "github.com/ProtonMail/proton-bridge/internal/preferences" + "github.com/ProtonMail/proton-bridge/pkg/config" + "github.com/ProtonMail/proton-bridge/pkg/listener" + + "github.com/abiosoft/ishell" +) + +var ( + log = config.GetLogEntry("frontend/cli") //nolint[gochecknoglobals] +) + +type frontendCLI struct { + *ishell.Shell + + config *config.Config + preferences *config.Preferences + eventListener listener.Listener + updates types.Updater + bridge types.Bridger + + appRestart bool +} + +// New returns a new CLI frontend configured with the given options. +func New( //nolint[funlen] + panicHandler types.PanicHandler, + config *config.Config, + preferences *config.Preferences, + eventListener listener.Listener, + updates types.Updater, + bridge types.Bridger, +) *frontendCLI { //nolint[golint] + fe := &frontendCLI{ + Shell: ishell.New(), + + config: config, + preferences: preferences, + eventListener: eventListener, + updates: updates, + bridge: bridge, + + appRestart: false, + } + + // Clear commands. + clearCmd := &ishell.Cmd{Name: "clear", + Help: "remove stored accounts and preferences. (alias: cl)", + Aliases: []string{"cl"}, + } + clearCmd.AddCmd(&ishell.Cmd{Name: "cache", + Help: "remove stored preferences for accounts (aliases: c, prefs, preferences)", + Aliases: []string{"c", "prefs", "preferences"}, + Func: fe.deleteCache, + }) + clearCmd.AddCmd(&ishell.Cmd{Name: "accounts", + Help: "remove all accounts from keychain. (aliases: k, keychain)", + Aliases: []string{"a", "k", "keychain"}, + Func: fe.deleteAccounts, + }) + fe.AddCmd(clearCmd) + + // Change commands. + changeCmd := &ishell.Cmd{Name: "change", + Help: "change server or account settings (aliases: ch, switch)", + Aliases: []string{"ch", "switch"}, + } + changeCmd.AddCmd(&ishell.Cmd{Name: "mode", + Help: "switch between combined addresses and split addresses mode for account. Use index or account name as parameter. (alias: m)", + Aliases: []string{"m"}, + Func: fe.changeMode, + Completer: fe.completeUsernames, + }) + changeCmd.AddCmd(&ishell.Cmd{Name: "port", + Help: "change port numbers of IMAP and SMTP servers. (alias: p)", + Aliases: []string{"p"}, + Func: fe.changePort, + }) + changeCmd.AddCmd(&ishell.Cmd{Name: "proxy", + Help: "allow or disallow bridge to securely connect to proton via a third party when it is being blocked", + Func: fe.toggleAllowProxy, + }) + changeCmd.AddCmd(&ishell.Cmd{Name: "smtp-security", + Help: "change port numbers of IMAP and SMTP servers.(alias: ssl, starttls)", + Aliases: []string{"ssl", "starttls"}, + Func: fe.changeSMTPSecurity, + }) + fe.AddCmd(changeCmd) + + // Check commands. + checkCmd := &ishell.Cmd{Name: "check", Help: "check internet connection or new version."} + checkCmd.AddCmd(&ishell.Cmd{Name: "updates", + Help: "check for Bridge updates. (aliases: u, v, version)", + Aliases: []string{"u", "version", "v"}, + Func: fe.checkUpdates, + }) + checkCmd.AddCmd(&ishell.Cmd{Name: "internet", + Help: "check internet connection. (aliases: i, conn, connection)", + Aliases: []string{"i", "con", "connection"}, + Func: fe.checkInternetConnection, + }) + fe.AddCmd(checkCmd) + + // Print info commands. + fe.AddCmd(&ishell.Cmd{Name: "log-dir", + Help: "print path to directory with logs. (aliases: log, logs)", + Aliases: []string{"log", "logs"}, + Func: fe.printLogDir, + }) + fe.AddCmd(&ishell.Cmd{Name: "manual", + Help: "print URL with instructions. (alias: man)", + Aliases: []string{"man"}, + Func: fe.printManual, + }) + fe.AddCmd(&ishell.Cmd{Name: "release-notes", + Help: "print release notes. (aliases: notes, fixed-bugs, bugs, ver, version)", + Aliases: []string{"notes", "fixed-bugs", "bugs", "ver", "version"}, + Func: fe.printLocalReleaseNotes, + }) + fe.AddCmd(&ishell.Cmd{Name: "credits", + Help: "print used resources.", + Func: fe.printCredits, + }) + + // Account commands. + fe.AddCmd(&ishell.Cmd{Name: "list", + Help: "print the list of accounts. (aliases: l, ls)", + Func: fe.noAccountWrapper(fe.listAccounts), + Aliases: []string{"l", "ls"}, + }) + fe.AddCmd(&ishell.Cmd{Name: "info", + Help: "print the configuration for account. Use index or account name as parameter. (alias: i)", + Func: fe.noAccountWrapper(fe.showAccountInfo), + Completer: fe.completeUsernames, + Aliases: []string{"i"}, + }) + fe.AddCmd(&ishell.Cmd{Name: "login", + Help: "login procedure to add or connect account. Optionally use index or account as parameter. (aliases: a, add, con, connect)", + Func: fe.loginAccount, + Aliases: []string{"add", "a", "con", "connect"}, + Completer: fe.completeUsernames, + }) + fe.AddCmd(&ishell.Cmd{Name: "logout", + Help: "disconnect the account. Use index or account name as parameter. (aliases: d, disconnect)", + Func: fe.noAccountWrapper(fe.logoutAccount), + Aliases: []string{"d", "disconnect"}, + Completer: fe.completeUsernames, + }) + fe.AddCmd(&ishell.Cmd{Name: "delete", + Help: "remove the account from keychain. Use index or account name as parameter. (aliases: del, rm, remove)", + Func: fe.noAccountWrapper(fe.deleteAccount), + Aliases: []string{"del", "rm", "remove"}, + Completer: fe.completeUsernames, + }) + + // System commands. + fe.AddCmd(&ishell.Cmd{Name: "restart", + Help: "restart the bridge.", + Func: fe.restart, + }) + + go func() { + defer panicHandler.HandlePanic() + fe.watchEvents() + }() + fe.eventListener.RetryEmit(events.TLSCertIssue) + fe.eventListener.RetryEmit(events.ErrorEvent) + return fe +} + +func (f *frontendCLI) watchEvents() { + errorCh := f.getEventChannel(events.ErrorEvent) + internetOffCh := f.getEventChannel(events.InternetOffEvent) + internetOnCh := f.getEventChannel(events.InternetOnEvent) + addressChangedCh := f.getEventChannel(events.AddressChangedEvent) + addressChangedLogoutCh := f.getEventChannel(events.AddressChangedLogoutEvent) + logoutCh := f.getEventChannel(events.LogoutEvent) + certIssue := f.getEventChannel(events.TLSCertIssue) + for { + select { + case errorDetails := <-errorCh: + f.Println("Bridge failed:", errorDetails) + case <-internetOffCh: + f.notifyInternetOff() + case <-internetOnCh: + f.notifyInternetOn() + case address := <-addressChangedCh: + f.Printf("Address changed for %s. You may need to reconfigure your email client.", address) + case address := <-addressChangedLogoutCh: + f.notifyLogout(address) + case userID := <-logoutCh: + user, err := f.bridge.GetUser(userID) + if err != nil { + return + } + f.notifyLogout(user.Username()) + case <-certIssue: + f.notifyCertIssue() + } + } +} + +func (f *frontendCLI) getEventChannel(event string) <-chan string { + ch := make(chan string) + f.eventListener.Add(event, ch) + return ch +} + +// IsAppRestarting returns whether the app is currently set to restart. +func (f *frontendCLI) IsAppRestarting() bool { + return f.appRestart +} + +// Loop starts the frontend loop with an interactive shell. +func (f *frontendCLI) Loop(credentialsError error) error { + if credentialsError != nil { + f.notifyCredentialsError() + return credentialsError + } + + f.preferences.SetBool(preferences.FirstStartKey, false) + + f.Print(` + Welcome to ProtonMail Bridge interactive shell + ___....___ + ^^ __..-:'':__:..:__:'':-..__ + _.-:__:.-:'': : : :'':-.:__:-._ + .':.-: : : : : : : : : :._:'. + _ :.': : : : : : : : : : : :'.: _ + [ ]: : : : : : : : : : : : : :[ ] + [ ]: : : : : : : : : : : : : :[ ] + :::::::::[ ]:__:__:__:__:__:__:__:__:__:__:__:__:__:[ ]::::::::::: + !!!!!!!!![ ]!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!![ ]!!!!!!!!!!! + ^^^^^^^^^[ ]^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^[ ]^^^^^^^^^^^ + [ ] [ ] + [ ] [ ] + jgs [ ] [ ] + ~~^_~^~/ \~^-~^~ _~^-~_^~-^~_^~~-^~_~^~-~_~-^~_^/ \~^ ~~_ ^ +`) + f.Run() + return nil +} diff --git a/internal/frontend/cli/system.go b/internal/frontend/cli/system.go new file mode 100644 index 00000000..4fe14702 --- /dev/null +++ b/internal/frontend/cli/system.go @@ -0,0 +1,164 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package cli + +import ( + "fmt" + "strconv" + "strings" + + "github.com/ProtonMail/proton-bridge/internal/bridge" + "github.com/ProtonMail/proton-bridge/internal/preferences" + "github.com/ProtonMail/proton-bridge/pkg/connection" + "github.com/ProtonMail/proton-bridge/pkg/ports" + "github.com/abiosoft/ishell" +) + +var ( + currentPort = "" //nolint[gochecknoglobals] +) + +func (f *frontendCLI) restart(c *ishell.Context) { + if f.yesNoQuestion("Are you sure you want to restart the Bridge") { + f.Println("Restarting Bridge...") + f.appRestart = true + f.Stop() + } +} + +func (f *frontendCLI) checkInternetConnection(c *ishell.Context) { + if connection.CheckInternetConnection() == nil { + f.Println("Internet connection is available.") + } else { + f.Println("Can not contact server please check you internet connection.") + } +} + +func (f *frontendCLI) printLogDir(c *ishell.Context) { + f.Println("Log files are stored in\n\n ", f.config.GetLogDir()) +} + +func (f *frontendCLI) printManual(c *ishell.Context) { + f.Println("More instructions about the Bridge can be found at\n\n https://protonmail.com/bridge") +} + +func (f *frontendCLI) deleteCache(c *ishell.Context) { + f.ShowPrompt(false) + defer f.ShowPrompt(true) + + if !f.yesNoQuestion("Do you really want to remove all stored preferences") { + return + } + if err := f.bridge.ClearData(); err != nil { + f.printAndLogError("Cache clear failed: ", err.Error()) + return + } + f.Println("Cached cleared, restarting bridge") + // Clearing data removes everything (db, preferences, ...) + // so everything has to be stopped and started again. + f.appRestart = true + f.Stop() +} + +func (f *frontendCLI) changeSMTPSecurity(c *ishell.Context) { + f.ShowPrompt(false) + defer f.ShowPrompt(true) + + isSSL := f.preferences.GetBool(preferences.SMTPSSLKey) + newSecurity := "SSL" + if isSSL { + newSecurity = "STARTTLS" + } + + msg := fmt.Sprintf("Are you sure you want to change SMTP setting to %q and restart the Bridge", newSecurity) + + if f.yesNoQuestion(msg) { + f.preferences.SetBool(preferences.SMTPSSLKey, !isSSL) + f.Println("Restarting Bridge...") + f.appRestart = true + f.Stop() + } +} + +func (f *frontendCLI) changePort(c *ishell.Context) { + f.ShowPrompt(false) + defer f.ShowPrompt(true) + + currentPort = f.preferences.Get(preferences.IMAPPortKey) + newIMAPPort := f.readStringInAttempts("Set IMAP port (current "+currentPort+")", c.ReadLine, f.isPortFree) + if newIMAPPort == "" { + newIMAPPort = currentPort + } + imapPortChanged := newIMAPPort != currentPort + + currentPort = f.preferences.Get(preferences.SMTPPortKey) + newSMTPPort := f.readStringInAttempts("Set SMTP port (current "+currentPort+")", c.ReadLine, f.isPortFree) + if newSMTPPort == "" { + newSMTPPort = currentPort + } + smtpPortChanged := newSMTPPort != currentPort + + if newIMAPPort == newSMTPPort { + f.Println("SMTP and IMAP ports must be different!") + return + } + + if imapPortChanged || smtpPortChanged { + f.Println("Saving values IMAP:", newIMAPPort, "SMTP:", newSMTPPort) + f.preferences.Set(preferences.IMAPPortKey, newIMAPPort) + f.preferences.Set(preferences.SMTPPortKey, newSMTPPort) + f.Println("Restarting Bridge...") + f.appRestart = true + f.Stop() + } else { + f.Println("Nothing changed") + } +} + +func (f *frontendCLI) toggleAllowProxy(c *ishell.Context) { + if f.preferences.GetBool(preferences.AllowProxyKey) { + f.Println("Bridge is currently set to use alternative routing to connect to Proton if it is being blocked.") + if f.yesNoQuestion("Are you sure you want to stop bridge from doing this") { + f.preferences.SetBool(preferences.AllowProxyKey, false) + bridge.DisallowDoH() + } + } else { + f.Println("Bridge is currently set to NOT use alternative routing to connect to Proton if it is being blocked.") + if f.yesNoQuestion("Are you sure you want to allow bridge to do this") { + f.preferences.SetBool(preferences.AllowProxyKey, true) + bridge.AllowDoH() + } + } +} + +func (f *frontendCLI) isPortFree(port string) bool { + port = strings.Replace(port, ":", "", -1) + if port == "" || port == currentPort { + return true + } + number, err := strconv.Atoi(port) + if err != nil || number < 0 || number > 65535 { + f.Println("Input", port, "is not a valid port number.") + return false + } + if !ports.IsPortFree(number) { + f.Println("Port", number, "is occupied by another process.") + return false + } + return true +} diff --git a/internal/frontend/cli/updates.go b/internal/frontend/cli/updates.go new file mode 100644 index 00000000..c72b0b76 --- /dev/null +++ b/internal/frontend/cli/updates.go @@ -0,0 +1,65 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package cli + +import ( + "strings" + + "github.com/ProtonMail/proton-bridge/internal/bridge" + "github.com/ProtonMail/proton-bridge/pkg/updates" + "github.com/abiosoft/ishell" +) + +func (f *frontendCLI) checkUpdates(c *ishell.Context) { + isUpToDate, latestVersionInfo, err := f.updates.CheckIsBridgeUpToDate() + if err != nil { + f.printAndLogError("Cannot retrieve version info: ", err) + f.checkInternetConnection(c) + return + } + if isUpToDate { + f.Println("Your version is up to date.") + } else { + f.notifyNeedUpgrade() + f.Println("") + f.printReleaseNotes(latestVersionInfo) + } +} + +func (f *frontendCLI) printLocalReleaseNotes(c *ishell.Context) { + localVersion := f.updates.GetLocalVersion() + f.printReleaseNotes(localVersion) +} + +func (f *frontendCLI) printReleaseNotes(versionInfo updates.VersionInfo) { + f.Println(bold("ProtonMail Bridge "+versionInfo.Version), "\n") + if versionInfo.ReleaseNotes != "" { + f.Println(bold("Release Notes")) + f.Println(versionInfo.ReleaseNotes) + } + if versionInfo.ReleaseFixedBugs != "" { + f.Println(bold("Fixed bugs")) + f.Println(versionInfo.ReleaseFixedBugs) + } +} + +func (f *frontendCLI) printCredits(c *ishell.Context) { + for _, pkg := range strings.Split(bridge.Credits, ";") { + f.Println(pkg) + } +} diff --git a/internal/frontend/cli/utils.go b/internal/frontend/cli/utils.go new file mode 100644 index 00000000..f5a97000 --- /dev/null +++ b/internal/frontend/cli/utils.go @@ -0,0 +1,123 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package cli + +import ( + "strings" + + pmapi "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/fatih/color" +) + +const ( + maxInputRepeat = 2 +) + +var ( + bold = color.New(color.Bold).SprintFunc() //nolint[gochecknoglobals] +) + +func isNotEmpty(val string) bool { + return val != "" +} + +func (f *frontendCLI) yesNoQuestion(question string) bool { + f.Print(question, "? yes/"+bold("no")+": ") + yes := "yes" + answer := strings.ToLower(f.ReadLine()) + for i := 0; i < len(answer); i++ { + if i >= len(yes) || answer[i] != yes[i] { + return false // Everything else is false. + } + } + return len(answer) > 0 // Empty is false. +} + +func (f *frontendCLI) readStringInAttempts(title string, readFunc func() string, isOK func(string) bool) (value string) { + f.Printf("%s: ", title) + value = readFunc() + title = strings.ToLower(string(title[0])) + title[1:] + for i := 0; !isOK(value); i++ { + if i >= maxInputRepeat { + f.Println("Too many attempts") + return "" + } + f.Printf("Please fill %s: ", title) + value = readFunc() + } + return +} + +func (f *frontendCLI) printAndLogError(args ...interface{}) { + log.Error(args...) + f.Println(args...) +} + +func (f *frontendCLI) processAPIError(err error) { + log.Warn("API error: ", err) + switch err { + case pmapi.ErrAPINotReachable: + f.notifyInternetOff() + case pmapi.ErrUpgradeApplication: + f.notifyNeedUpgrade() + default: + f.Println("Server error:", err.Error()) + } +} + +func (f *frontendCLI) notifyInternetOff() { + f.Println("Internet connection is not available.") +} + +func (f *frontendCLI) notifyInternetOn() { + f.Println("Internet connection is available again.") +} + +func (f *frontendCLI) notifyLogout(address string) { + f.Printf("Account %s is disconnected. Login to continue using this account with email client.", address) +} + +func (f *frontendCLI) notifyNeedUpgrade() { + f.Println("Please download and install the newest version of application from", f.updates.GetDownloadLink()) +} + +func (f *frontendCLI) notifyCredentialsError() { + // Print in 80-column width. + f.Println("ProtonMail Bridge is not able to detect a supported password manager") + f.Println("(pass, gnome-keyring). Please install and set up a supported password manager") + f.Println("and restart the application.") +} + +func (f *frontendCLI) notifyCertIssue() { + // Print in 80-column width. + f.Println(`Connection security error: Your network connection to Proton services may +be insecure. + +Description: +ProtonMail Bridge was not able to establish a secure connection to Proton +servers due to a TLS certificate error. This means your connection may +potentially be insecure and susceptible to monitoring by third parties. + +Recommendation: +* If you trust your network operator, you can continue to use ProtonMail + as usual. +* If you don't trust your network operator, reconnect to ProtonMail over a VPN + (such as ProtonVPN) which encrypts your Internet connection, or use + a different network to access ProtonMail. +`) +} diff --git a/internal/frontend/frontend.go b/internal/frontend/frontend.go new file mode 100644 index 00000000..96d45fa4 --- /dev/null +++ b/internal/frontend/frontend.go @@ -0,0 +1,87 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Package frontend provides all interfaces of the Bridge. +package frontend + +import ( + "github.com/0xAX/notificator" + "github.com/ProtonMail/proton-bridge/internal/bridge" + "github.com/ProtonMail/proton-bridge/internal/frontend/cli" + "github.com/ProtonMail/proton-bridge/internal/frontend/qt" + "github.com/ProtonMail/proton-bridge/internal/frontend/types" + "github.com/ProtonMail/proton-bridge/pkg/config" + "github.com/ProtonMail/proton-bridge/pkg/listener" +) + +var ( + log = config.GetLogEntry("frontend") // nolint[unused] +) + +// Frontend is an interface to be implemented by each frontend type (cli, gui, html). +type Frontend interface { + Loop(credentialsError error) error + IsAppRestarting() bool +} + +// HandlePanic handles panics which occur for users with GUI. +func HandlePanic() { + notify := notificator.New(notificator.Options{ + DefaultIcon: "../frontend/ui/icon/icon.png", + AppName: "ProtonMail Bridge", + }) + _ = notify.Push("Fatal Error", "The ProtonMail Bridge has encountered a fatal error. ", "/frontend/icon/icon.png", notificator.UR_CRITICAL) +} + +// New returns initialized frontend based on `frontendType`, which can be `cli` or `qt`. +func New( + version, + buildVersion, + frontendType string, + showWindowOnStart bool, + panicHandler types.PanicHandler, + config *config.Config, + preferences *config.Preferences, + eventListener listener.Listener, + updates types.Updater, + bridge *bridge.Bridge, + noEncConfirmator types.NoEncConfirmator, +) Frontend { + bridgeWrap := types.NewBridgeWrap(bridge) + return new(version, buildVersion, frontendType, showWindowOnStart, panicHandler, config, preferences, eventListener, updates, bridgeWrap, noEncConfirmator) +} + +func new( + version, + buildVersion, + frontendType string, + showWindowOnStart bool, + panicHandler types.PanicHandler, + config *config.Config, + preferences *config.Preferences, + eventListener listener.Listener, + updates types.Updater, + bridge types.Bridger, + noEncConfirmator types.NoEncConfirmator, +) Frontend { + switch frontendType { + case "cli": + return cli.New(panicHandler, config, preferences, eventListener, updates, bridge) + default: + return qt.New(version, buildVersion, showWindowOnStart, panicHandler, config, preferences, eventListener, updates, bridge, noEncConfirmator) + } +} diff --git a/internal/frontend/qml/BridgeUI/AccountDelegate.qml b/internal/frontend/qml/BridgeUI/AccountDelegate.qml new file mode 100644 index 00000000..da264759 --- /dev/null +++ b/internal/frontend/qml/BridgeUI/AccountDelegate.qml @@ -0,0 +1,430 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +import QtQuick 2.8 +import ProtonUI 1.0 +import BridgeUI 1.0 + +// NOTE: Keep the Column so the height and width is inherited from content +Column { + id: root + state: status + anchors.left: parent.left + + property int row_width: 50 * Style.px + property int row_height: Style.accounts.heightAccount + property var listalias : aliases.split(";") + property int iAccount: index + + Accessible.role: go.goos=="windows" ? Accessible.Grouping : Accessible.Row + Accessible.name: qsTr("Account %1, status %2", "Accessible text describing account row with arguments: account name and status (connected/disconnected), resp.").arg(account).arg(statusMark.text) + Accessible.description: Accessible.name + Accessible.ignored: !enabled || !visible + + // Main row + Rectangle { + id: mainaccRow + anchors.left: parent.left + width : row_width + height : row_height + state: { return isExpanded ? "expanded" : "collapsed" } + color: Style.main.background + + property string actionName : ( + isExpanded ? + qsTr("Collapse row for account %2", "Accessible text of button showing additional configuration of account") : + qsTr("Expand row for account %2", "Accessible text of button hiding additional configuration of account") + ). arg(account) + + + // override by other buttons + MouseArea { + id: mouseArea + anchors.fill: parent + onClicked : { + if (root.state=="connected") { + mainaccRow.toggle_accountSettings() + } + } + cursorShape : root.state == "connected" ? Qt.PointingHandCursor : Qt.ArrowCursor + hoverEnabled: true + onEntered: { + if (mainaccRow.state=="collapsed") { + mainaccRow.color = Qt.lighter(Style.main.background,1.1) + } + } + onExited: { + if (mainaccRow.state=="collapsed") { + mainaccRow.color = Style.main.background + } + } + } + + // toggle down/up icon + Text { + id: toggleIcon + anchors { + left : parent.left + verticalCenter : parent.verticalCenter + leftMargin : Style.main.leftMargin + } + color: Style.main.text + font { + pointSize : Style.accounts.sizeChevron * Style.pt + family : Style.fontawesome.name + } + text: Style.fa.chevron_down + + MouseArea { + anchors.fill: parent + Accessible.role: Accessible.Button + Accessible.name: mainaccRow.actionName + Accessible.description: mainaccRow.actionName + Accessible.onPressAction : mainaccRow.toggle_accountSettings() + Accessible.ignored: root.state!="connected" || !root.enabled + } + } + + // account name + TextMetrics { + id: accountMetrics + font : accountName.font + elide: Qt.ElideMiddle + elideWidth: Style.accounts.elideWidth + text: account + } + Text { + id: accountName + anchors { + verticalCenter : parent.verticalCenter + left : toggleIcon.left + leftMargin : Style.main.leftMargin + } + color: Style.main.text + font { + pointSize : (Style.main.fontSize+2*Style.px) * Style.pt + } + text: accountMetrics.elidedText + } + + // status + ClickIconText { + id: statusMark + anchors { + verticalCenter : parent.verticalCenter + left : parent.left + leftMargin : Style.accounts.leftMargin2 + } + text : qsTr("connected", "status of a listed logged-in account") + iconText : Style.fa.circle_o + textColor : Style.main.textGreen + enabled : false + Accessible.ignored: true + } + + // logout + ClickIconText { + id: logoutAccount + anchors { + verticalCenter : parent.verticalCenter + left : parent.left + leftMargin : Style.accounts.leftMargin3 + } + text : qsTr("Log out", "action to log out a connected account") + iconText : Style.fa.power_off + textBold : true + textColor : Style.main.textBlue + } + + // remove + ClickIconText { + id: deleteAccount + anchors { + verticalCenter : parent.verticalCenter + right : parent.right + rightMargin : Style.main.rightMargin + } + text : qsTr("Remove", "deletes an account from the account settings page") + iconText : Style.fa.trash_o + textColor : Style.main.text + onClicked : { + dialogGlobal.input=root.iAccount + dialogGlobal.state="deleteUser" + dialogGlobal.show() + } + } + + + // functions + function toggle_accountSettings() { + if (root.state=="connected") { + if (mainaccRow.state=="collapsed" ) { + mainaccRow.state="expanded" + } else { + mainaccRow.state="collapsed" + } + } + } + + states: [ + State { + name: "collapsed" + PropertyChanges { target : toggleIcon ; text : root.state=="connected" ? Style.fa.chevron_down : " " } + PropertyChanges { target : accountName ; font.bold : false } + PropertyChanges { target : mainaccRow ; color : Style.main.background } + PropertyChanges { target : addressList ; visible : false } + }, + State { + name: "expanded" + PropertyChanges { target : toggleIcon ; text : Style.fa.chevron_up } + PropertyChanges { target : accountName ; font.bold : true } + PropertyChanges { target : mainaccRow ; color : Style.accounts.backgroundExpanded } + PropertyChanges { target : addressList ; visible : true } + } + ] + } + + // List of adresses + Column { + id: addressList + anchors.left : parent.left + width: row_width + visible: false + property alias model : repeaterAddresses.model + + Rectangle { + id: addressModeWrapper + anchors { + left : parent.left + right : parent.right + } + visible : mainaccRow.state=="expanded" + height : 2*Style.accounts.heightAddrRow/3 + color : Style.accounts.backgroundExpanded + + ClickIconText { + id: addressModeSwitch + anchors { + top : addressModeWrapper.top + right : addressModeWrapper.right + rightMargin : Style.main.rightMargin + } + textColor : Style.main.textBlue + iconText : Style.fa.exchange + iconOnRight : false + text : isCombinedAddressMode ? + qsTr("Switch to split addresses mode", "Text of button switching to mode with one configuration per each address.") : + qsTr("Switch to combined addresses mode", "Text of button switching to mode with one configuration for all addresses.") + + onClicked: { + dialogGlobal.input=root.iAccount + dialogGlobal.state="addressmode" + dialogGlobal.show() + } + } + + ClickIconText { + id: combinedAddressConfig + anchors { + top : addressModeWrapper.top + left : addressModeWrapper.left + leftMargin : Style.accounts.leftMarginAddr+Style.main.leftMargin + } + visible : isCombinedAddressMode + text : qsTr("Mailbox configuration", "Displays IMAP/SMTP settings information for a given account") + iconText : Style.fa.gear + textColor : Style.main.textBlue + onClicked : { + infoWin.showInfo(root.iAccount,0) + } + } + } + + Repeater { + id: repeaterAddresses + model: ["one", "two"] + + Rectangle { + id: addressRow + visible: !isCombinedAddressMode + anchors { + left : parent.left + right : parent.right + } + height: Style.accounts.heightAddrRow + color: Style.accounts.backgroundExpanded + + // icon level down + Text { + id: levelDown + anchors { + left : parent.left + leftMargin : Style.accounts.leftMarginAddr + verticalCenter : wrapAddr.verticalCenter + } + text : Style.fa.level_up + font.family : Style.fontawesome.name + color : Style.main.textDisabled + rotation : 90 + } + + Rectangle { + id: wrapAddr + anchors { + top : parent.top + left : levelDown.right + right : parent.right + leftMargin : Style.main.leftMargin + rightMargin : Style.main.rightMargin + } + height: Style.accounts.heightAddr + border { + width : Style.main.border + color : Style.main.line + } + color: Style.accounts.backgroundAddrRow + + TextMetrics { + id: addressMetrics + font: address.font + elideWidth: 2*wrapAddr.width/3 + elide: Qt.ElideMiddle + text: modelData + } + + Text { + id: address + anchors { + verticalCenter : parent.verticalCenter + left: parent.left + leftMargin: Style.main.leftMargin + } + font.pointSize : Style.main.fontSize * Style.pt + color: Style.main.text + text: addressMetrics.elidedText + } + + ClickIconText { + id: addressConfig + anchors { + verticalCenter : parent.verticalCenter + right: parent.right + rightMargin: Style.main.rightMargin + } + text : qsTr("Address configuration", "Display the IMAP/SMTP configuration for address") + iconText : Style.fa.gear + textColor : Style.main.textBlue + onClicked : infoWin.showInfo(root.iAccount,index) + + Accessible.description: qsTr("Address configuration for %1", "Accessible text of button displaying the IMAP/SMTP configuration for address %1").arg(modelData) + Accessible.ignored: !enabled + } + + MouseArea { + id: clickSettings + anchors.fill: wrapAddr + onClicked : addressConfig.clicked() + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + onPressed: { + wrapAddr.color = Qt.rgba(1,1,1,0.20) + } + onEntered: { + wrapAddr.color = Qt.rgba(1,1,1,0.15) + } + onExited: { + wrapAddr.color = Style.accounts.backgroundAddrRow + } + } + } + } + } + } + + Rectangle { + id: line + color: Style.accounts.line + height: Style.accounts.heightLine + width: root.row_width + } + + + states: [ + State { + name: "connected" + PropertyChanges { + target : addressList + model : listalias + } + PropertyChanges { + target : toggleIcon + color : Style.main.text + } + PropertyChanges { + target : accountName + color : Style.main.text + } + PropertyChanges { + target : statusMark + textColor : Style.main.textGreen + text : qsTr("connected", "status of a listed logged-in account") + iconText : Style.fa.circle + } + PropertyChanges { + target : logoutAccount + text : qsTr("Log out", "action to log out a connected account") + onClicked : { + mainaccRow.state="collapsed" + dialogGlobal.input = root.iAccount + dialogGlobal.state = "logout" + dialogGlobal.show() + dialogGlobal.confirmed() + } + } + }, + State { + name: "disconnected" + PropertyChanges { + target : addressList + model : 0 + } + PropertyChanges { + target : toggleIcon + color : Style.main.textDisabled + } + PropertyChanges { + target : accountName + color : Style.main.textDisabled + } + PropertyChanges { + target : statusMark + textColor : Style.main.textDisabled + text : qsTr("disconnected", "status of a listed logged-out account") + iconText : Style.fa.circle_o + } + PropertyChanges { + target : logoutAccount + text : qsTr("Log in", "action to log in a disconnected account") + onClicked : { + dialogAddUser.username = root.listalias[0] + dialogAddUser.show() + dialogAddUser.inputPassword.focusInput = true + } + } + } + ] +} diff --git a/internal/frontend/qml/BridgeUI/BubbleMenu.qml b/internal/frontend/qml/BridgeUI/BubbleMenu.qml new file mode 100644 index 00000000..690852ba --- /dev/null +++ b/internal/frontend/qml/BridgeUI/BubbleMenu.qml @@ -0,0 +1,72 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Dialog with main menu + +import QtQuick 2.8 +import BridgeUI 1.0 +import ProtonUI 1.0 + +Rectangle { + id: root + color: "#aaff5577" + anchors { + left : tabbar.left + right : tabbar.right + top : tabbar.bottom + bottom : parent.bottom + } + visible: false + + MouseArea { + anchors.fill: parent + onClicked: toggle() + } + + Rectangle { + color : Style.menu.background + radius : Style.menu.radius + width : Style.menu.width + height : Style.menu.height + anchors { + top : parent.top + right : parent.right + topMargin : Style.menu.topMargin + rightMargin : Style.menu.rightMargin + } + + MouseArea { + anchors.fill: parent + } + + Text { + anchors.centerIn: parent + text: qsTr("About") + color: Style.menu.text + } + } + + function toggle(){ + if (root.visible == false) { + root.visible = true + } else { + root.visible = false + } + } +} + + diff --git a/internal/frontend/qml/BridgeUI/Credits.qml b/internal/frontend/qml/BridgeUI/Credits.qml new file mode 100644 index 00000000..abf07e0d --- /dev/null +++ b/internal/frontend/qml/BridgeUI/Credits.qml @@ -0,0 +1,49 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +import QtQuick 2.8 +import BridgeUI 1.0 +import ProtonUI 1.0 + +Item { + Rectangle { + anchors.centerIn: parent + width: Style.main.width + height: 3*Style.main.height/4 + color: "transparent" + //color: "red" + + ListView { + anchors.fill: parent + clip : true + model : go.credits.split(";") + + delegate: AccessibleText { + anchors.horizontalCenter: parent.horizontalCenter + text: modelData + color: Style.main.text + font.pointSize : Style.main.fontSize * Style.pt + } + + footer: ButtonRounded { + anchors.horizontalCenter: parent.horizontalCenter + text: qsTr("Close", "close window") + onClicked: dialogCredits.hide() + } + } + } +} diff --git a/internal/frontend/qml/BridgeUI/DialogFirstStart.qml b/internal/frontend/qml/BridgeUI/DialogFirstStart.qml new file mode 100644 index 00000000..80a7bdf8 --- /dev/null +++ b/internal/frontend/qml/BridgeUI/DialogFirstStart.qml @@ -0,0 +1,124 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Dialog with Yes/No buttons + +import QtQuick 2.8 +import ProtonUI 1.0 + +Dialog { + id: root + + title : "" + isDialogBusy: false + property string firstParagraph : qsTr("The Bridge is an application that runs on your computer in the background and seamlessly encrypts and decrypts your mail as it enters and leaves your computer.", "instructions that appear on welcome screen at first start") + property string secondParagraph : qsTr("To add your ProtonMail account to the Bridge and generate your Bridge password, please see the installation guide for detailed setup instructions.", "confirms and dismisses a notification (URL that leads to installation guide should stay intact)") + + Column { + id: dialogMessage + property int heightInputs : welcome.height + middleSep.height + instructions.height + buttSep.height + buttonOkay.height + imageSep.height + logo.height + + Rectangle { color : "transparent"; width : Style.main.dummy; height : (root.height-dialogMessage.heightInputs)/2 } + + Text { + id:welcome + color: Style.main.text + font.bold: true + font.pointSize: 1.5*Style.main.fontSize*Style.pt + anchors.horizontalCenter: parent.horizontalCenter + horizontalAlignment: Text.AlignHCenter + text: qsTr("Welcome to the", "welcome screen that appears on first start") + } + + Rectangle {id: imageSep; color : "transparent"; width : Style.main.dummy; height : Style.dialog.heightSeparator } + + + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: Style.dialog.spacing + Image { + id: logo + anchors.bottom : pmbridge.baseline + height : 2*Style.main.fontSize + fillMode : Image.PreserveAspectFit + mipmap : true + source : "../ProtonUI/images/pm_logo.png" + } + AccessibleText { + id:pmbridge + color: Style.main.text + font.bold: true + font.pointSize: 2.2*Style.main.fontSize*Style.pt + horizontalAlignment: Text.AlignHCenter + text: qsTr("ProtonMail Bridge", "app title") + + Accessible.name: this.clearText(pmbridge.text) + Accessible.description: this.clearText(welcome.text+ " " + pmbridge.text + ". " + root.firstParagraph + ". " + root.secondParagraph) + } + } + + + + Rectangle { id:middleSep; color : "transparent"; width : Style.main.dummy; height : Style.dialog.heightSeparator } + + + Text { + id:instructions + color: Style.main.text + font.pointSize: Style.main.fontSize*Style.pt + anchors.horizontalCenter: parent.horizontalCenter + horizontalAlignment: Text.AlignHCenter + width: root.width/1.5 + wrapMode: Text.Wrap + textFormat: Text.RichText + text: ""+ + root.firstParagraph + + "

"+ + root.secondParagraph + + "" + onLinkActivated: { + Qt.openUrlExternally(link) + } + } + + Rectangle { id:buttSep; color : "transparent"; width : Style.main.dummy; height : 2*Style.dialog.heightSeparator } + + + ButtonRounded { + id:buttonOkay + color_main: Style.dialog.text + color_minor: Style.main.textBlue + isOpaque: true + fa_icon: Style.fa.check + text: qsTr("Okay", "confirms and dismisses a notification") + onClicked : root.hide() + anchors.horizontalCenter: parent.horizontalCenter + } + } + + timer.interval : 3000 + + Connections { + target: timer + onTriggered: { + } + } + + onShow : { + pmbridge.Accessible.selected = true + } +} diff --git a/internal/frontend/qml/BridgeUI/DialogPortChange.qml b/internal/frontend/qml/BridgeUI/DialogPortChange.qml new file mode 100644 index 00000000..bb3aee8d --- /dev/null +++ b/internal/frontend/qml/BridgeUI/DialogPortChange.qml @@ -0,0 +1,233 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Dialog with Yes/No buttons + +import QtQuick 2.8 +import BridgeUI 1.0 +import ProtonUI 1.0 +import QtQuick.Controls 2.2 as QC + +Dialog { + id: root + + title : "Set IMAP & SMTP settings" + subtitle : "Changes require reconfiguration of Mail client. (Bridge will automatically restart)" + isDialogBusy: currentIndex==1 + + Column { + id: dialogMessage + property int heightInputs : imapPort.height + middleSep.height + smtpPort.height + buttonSep.height + buttonRow.height + secSMTPSep.height + securitySMTP.height + + Rectangle { color : "transparent"; width : Style.main.dummy; height : (root.height-dialogMessage.heightInputs)/1.6 } + + InputField { + id: imapPort + iconText : Style.fa.hashtag + label : qsTr("IMAP port", "entry field to choose port used for the IMAP server") + text : "undef" + } + + Rectangle { id:middleSep; color : "transparent"; width : Style.main.dummy; height : Style.dialog.heightSeparator } + + InputField { + id: smtpPort + iconText : Style.fa.hashtag + label : qsTr("SMTP port", "entry field to choose port used for the SMTP server") + text : "undef" + } + + Rectangle { id:secSMTPSep; color : Style.transparent; width : Style.main.dummy; height : Style.dialog.heightSeparator } + + // SSL button group + Rectangle { + anchors.horizontalCenter : parent.horizontalCenter + width : Style.dialog.widthInput + height : securitySMTPLabel.height + securitySMTP.height + color : "transparent" + + AccessibleText { + id: securitySMTPLabel + anchors.left : parent.left + text:qsTr("SMTP connection mode") + color: Style.dialog.text + font { + pointSize : Style.dialog.fontSize * Style.pt + bold : true + } + } + + QC.ButtonGroup { + buttons: securitySMTP.children + } + Row { + id: securitySMTP + spacing: Style.dialog.spacing + anchors.top: securitySMTPLabel.bottom + anchors.topMargin: Style.dialog.fontSize + + CheckBoxLabel { + id: securitySMTPSSL + text: qsTr("SSL") + } + + CheckBoxLabel { + checked: true + id: securitySMTPSTARTTLS + text: qsTr("STARTTLS") + } + } + } + + Rectangle { id:buttonSep; color : "transparent"; width : Style.main.dummy; height : 2*Style.dialog.heightSeparator } + + Row { + id: buttonRow + anchors.horizontalCenter: parent.horizontalCenter + spacing: Style.dialog.spacing + ButtonRounded { + id:buttonNo + color_main: Style.dialog.text + fa_icon: Style.fa.times + text: qsTr("Cancel", "dismisses current action") + onClicked : root.hide() + } + ButtonRounded { + id: buttonYes + color_main: Style.dialog.text + color_minor: Style.main.textBlue + isOpaque: true + fa_icon: Style.fa.check + text: qsTr("Okay", "confirms and dismisses a notification") + onClicked : root.confirmed() + } + } + } + + Column { + Rectangle { color : "transparent"; width : Style.main.dummy; height : (root.height-answ.height)/2 } + Text { + id: answ + anchors.horizontalCenter: parent.horizontalCenter + width : parent.width/2 + color: Style.dialog.text + font { + pointSize : Style.dialog.fontSize * Style.pt + bold : true + } + text : "IMAP: " + imapPort.text + "\nSMTP: " + smtpPort.text + "\nSMTP Connection Mode: " + getSelectedSSLMode() + "\n\n" + + qsTr("Settings will be applied after the next start. You will need to reconfigure your email client(s).", "after user changes their ports they will see this notification to reconfigure their setup") + + "\n\n" + + qsTr("Bridge will now restart.", "after user changes their ports this appears to notify the user of restart") + wrapMode: Text.Wrap + horizontalAlignment: Text.AlignHCenter + } + } + + function areInputsOK() { + var isOK = true + var imapUnchanged = false + var secSMTPUnchanged = (securitySMTPSTARTTLS.checked == go.isSMTPSTARTTLS()) + root.warning.text = "" + + if (imapPort.text!=go.getIMAPPort()) { + if (go.isPortOpen(imapPort.text)!=0) { + imapPort.rightIcon = Style.fa.exclamation_triangle + root.warning.text = qsTr("Port number is not available.", "if the user changes one of their ports to a port that is occupied by another application") + isOK=false + } else { + imapPort.rightIcon = Style.fa.check_circle + } + } else { + imapPort.rightIcon = "" + imapUnchanged = true + } + + if (smtpPort.text!=go.getSMTPPort()) { + if (go.isPortOpen(smtpPort.text)!=0) { + smtpPort.rightIcon = Style.fa.exclamation_triangle + root.warning.text = qsTr("Port number is not available.", "if the user changes one of their ports to a port that is occupied by another application") + isOK=false + } else { + smtpPort.rightIcon = Style.fa.check_circle + } + } else { + smtpPort.rightIcon = "" + if (imapUnchanged && secSMTPUnchanged) { + root.warning.text = qsTr("Please change at least one port number or SMTP security.", "if the user tries to change IMAP/SMTP ports to the same ports as before") + isOK=false + } + } + + if (imapPort.text == smtpPort.text) { + smtpPort.rightIcon = Style.fa.exclamation_triangle + root.warning.text = qsTr("Port numbers must be different.", "if the user sets both the IMAP and SMTP ports to the same number") + isOK=false + } + + root.warning.visible = !isOK + return isOK + } + + function confirmed() { + if (areInputsOK()) { + incrementCurrentIndex() + timer.start() + } + } + + function getSelectedSSLMode() { + if (securitySMTPSTARTTLS.checked == true) { + return "STARTTLS" + } else { + return "SSL" + } + } + + onShow : { + imapPort.text = go.getIMAPPort() + smtpPort.text = go.getSMTPPort() + if (go.isSMTPSTARTTLS()) { + securitySMTPSTARTTLS.checked = true + } else { + securitySMTPSSL.checked = true + } + areInputsOK() + root.warning.visible = false + } + + Shortcut { + sequence: StandardKey.Cancel + onActivated: root.hide() + } + + Shortcut { + sequence: "Enter" + onActivated: root.confirmed() + } + + timer.interval : 3000 + + Connections { + target: timer + onTriggered: { + go.setPortsAndSecurity(imapPort.text, smtpPort.text, securitySMTPSTARTTLS.checked) + go.isRestarting = true + Qt.quit() + } + } +} diff --git a/internal/frontend/qml/BridgeUI/DialogTLSCertInfo.qml b/internal/frontend/qml/BridgeUI/DialogTLSCertInfo.qml new file mode 100644 index 00000000..2b4552d3 --- /dev/null +++ b/internal/frontend/qml/BridgeUI/DialogTLSCertInfo.qml @@ -0,0 +1,77 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +import QtQuick 2.8 +import BridgeUI 1.0 +import ProtonUI 1.0 + + +Dialog { + id: root + title: qsTr("Connection security error", "Title of modal explainning TLS issue") + + property string par1Title : qsTr("Description:", "Title of paragraph describing the issue") + property string par1Text : qsTr ( + "ProtonMail Bridge was not able to establish a secure connection to Proton servers due to a TLS certificate error. "+ + "This means your connection may potentially be insecure and susceptible to monitoring by third parties.", + "A paragraph describing the issue" + ) + + property string par2Title : qsTr("Recommendation:", "Title of paragraph describing recommended steps") + property string par2Text : qsTr ( + "If you are on a corporate or public network, the network administrator may be monitoring or intercepting all traffic.", + "A paragraph describing network issue" + ) + property string par2ul1 : qsTr( + "If you trust your network operator, you can continue to use ProtonMail as usual.", + "A list item describing recomendation for trusted network" + ) + + property string par2ul2 : qsTr( + "If you don't trust your network operator, reconnect to ProtonMail over a VPN (such as ProtonVPN) "+ + "which encrypts your Internet connection, or use a different network to access ProtonMail.", + "A list item describing recomendation for untrusted network" + ) + property string par3Text : qsTr("Learn more on our knowledge base article","A paragraph describing where to find more information") + property string kbArticleText : qsTr("What is TLS certificate error.", "Link text for knowledge base article") + property string kbArticleLink : "https://protonmail.com/support/knowledge-base/" + + + Item { + AccessibleText { + anchors.centerIn: parent + color: Style.old.pm_white + linkColor: color + width: parent.width - 50 * Style.px + wrapMode: Text.WordWrap + font.pointSize: Style.main.fontSize*Style.pt + onLinkActivated: Qt.openUrlExternally(link) + text: "

"+par1Title+"

"+ + par1Text+"
\n"+ + "

"+par2Title+"

"+ + par2Text+ + "
    "+ + "
  • "+par2ul1+"
  • "+ + "
  • "+par2ul2+"
  • "+ + "
"+"
\n"+ + "" + //par3Text+ + //" "+kbArticleText+"\n" + } + } +} + diff --git a/internal/frontend/qml/BridgeUI/DialogYesNo.qml b/internal/frontend/qml/BridgeUI/DialogYesNo.qml new file mode 100644 index 00000000..649f7486 --- /dev/null +++ b/internal/frontend/qml/BridgeUI/DialogYesNo.qml @@ -0,0 +1,382 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Dialog with Yes/No buttons + +import QtQuick 2.8 +import BridgeUI 1.0 +import ProtonUI 1.0 + +Dialog { + id: root + + title : "" + + property string input + + property alias question : msg.text + property alias note : noteText.text + property alias answer : answ.text + property alias buttonYes : buttonYes + property alias buttonNo : buttonNo + + isDialogBusy: currentIndex==1 + + signal confirmed() + + Column { + id: dialogMessage + property int heightInputs : msg.height+ + middleSep.height+ + buttonRow.height + + (checkboxSep.visible ? checkboxSep.height : 0 ) + + (noteSep.visible ? noteSep.height : 0 ) + + (checkBoxWrapper.visible ? checkBoxWrapper.height : 0 ) + + (root.note!="" ? noteText.height : 0 ) + + Rectangle { color : "transparent"; width : Style.main.dummy; height : (root.height-dialogMessage.heightInputs)/2 } + + AccessibleText { + id:noteText + anchors.horizontalCenter: parent.horizontalCenter + color: Style.dialog.text + font { + pointSize: Style.dialog.fontSize * Style.pt + bold: false + } + width: 2*root.width/3 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + } + Rectangle { id: noteSep; visible: note!=""; color : "transparent"; width : Style.main.dummy; height : Style.dialog.heightSeparator} + + AccessibleText { + id: msg + anchors.horizontalCenter: parent.horizontalCenter + color: Style.dialog.text + font { + pointSize: Style.dialog.fontSize * Style.pt + bold: true + } + width: 2*parent.width/3 + text : "" + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + } + + Rectangle { id: checkboxSep; visible: checkBoxWrapper.visible; color : "transparent"; width : Style.main.dummy; height : Style.dialog.heightSeparator} + Row { + id: checkBoxWrapper + property bool isChecked : false + visible: root.state=="deleteUser" + anchors.horizontalCenter: parent.horizontalCenter + spacing: Style.dialog.spacing + + function toggle() { + checkBoxWrapper.isChecked = !checkBoxWrapper.isChecked + } + + Text { + id: checkbox + font { + pointSize : Style.dialog.iconSize * Style.pt + family : Style.fontawesome.name + } + anchors.verticalCenter : parent.verticalCenter + text: checkBoxWrapper.isChecked ? Style.fa.check_square_o : Style.fa.square_o + color: checkBoxWrapper.isChecked ? Style.main.textBlue : Style.main.text + + MouseArea { + anchors.fill: parent + onPressed: checkBoxWrapper.toggle() + cursorShape: Qt.PointingHandCursor + } + } + Text { + id: checkBoxNote + anchors.verticalCenter : parent.verticalCenter + text: qsTr("Additionally delete all stored preferences and data", "when removing an account, this extra preference additionally deletes all cached data") + color: Style.main.text + font.pointSize: Style.dialog.fontSize * Style.pt + + MouseArea { + anchors.fill: parent + onPressed: checkBoxWrapper.toggle() + cursorShape: Qt.PointingHandCursor + + Accessible.role: Accessible.CheckBox + Accessible.checked: checkBoxWrapper.isChecked + Accessible.name: checkBoxNote.text + Accessible.description: checkBoxNote.text + Accessible.ignored: checkBoxNote.text == "" + Accessible.onToggleAction: checkBoxWrapper.toggle() + Accessible.onPressAction: checkBoxWrapper.toggle() + } + } + } + + Rectangle { id: middleSep; color : "transparent"; width : Style.main.dummy; height : 2*Style.dialog.heightSeparator } + + Row { + id: buttonRow + anchors.horizontalCenter: parent.horizontalCenter + spacing: Style.dialog.spacing + ButtonRounded { + id:buttonNo + color_main: Style.dialog.text + fa_icon: Style.fa.times + text: qsTr("No") + onClicked : root.hide() + } + ButtonRounded { + id: buttonYes + color_main: Style.dialog.text + color_minor: Style.main.textBlue + isOpaque: true + fa_icon: Style.fa.check + text: qsTr("Yes") + onClicked : { + currentIndex=1 + root.confirmed() + } + } + } + } + + + Column { + Rectangle { color : "transparent"; width : Style.main.dummy; height : (root.height-answ.height)/2 } + AccessibleText { + id: answ + anchors.horizontalCenter: parent.horizontalCenter + color: Style.old.pm_white + font { + pointSize : Style.dialog.fontSize * Style.pt + bold : true + } + width: 3*parent.width/4 + horizontalAlignment: Text.AlignHCenter + text : qsTr("Waiting...", "in general this displays between screens when processing data takes a long time") + wrapMode: Text.Wrap + } + } + + + states : [ + State { + name: "quit" + PropertyChanges { + target: root + currentIndex : 0 + title : qsTr("Close Bridge", "quits the application") + question : qsTr("Are you sure you want to close the Bridge?", "asked when user tries to quit the application") + note : "" + answer : qsTr("Closing Bridge...", "displayed when user is quitting application") + } + }, + State { + name: "logout" + PropertyChanges { + target: root + currentIndex : 1 + title : qsTr("Logout", "title of page that displays during account logout") + question : "" + note : "" + answer : qsTr("Logging out...", "displays during account logout") + } + }, + State { + name: "deleteUser" + PropertyChanges { + target: root + currentIndex : 0 + title : qsTr("Delete account", "title of page that displays during account deletion") + question : qsTr("Are you sure you want to remove this account?", "displays during account deletion") + note : "" + answer : qsTr("Deleting ...", "displays during account deletion") + } + }, + State { + name: "clearChain" + PropertyChanges { + target : root + currentIndex : 0 + title : qsTr("Clear keychain", "title of page that displays during keychain clearing") + question : qsTr("Are you sure you want to clear your keychain?", "displays during keychain clearing") + note : qsTr("This will remove all accounts that you have added to the Bridge and disconnect you from your email client(s).", "displays during keychain clearing") + answer : qsTr("Clearing the keychain ...", "displays during keychain clearing") + } + }, + State { + name: "clearCache" + PropertyChanges { + target: root + currentIndex : 0 + title : qsTr("Clear cache", "title of page that displays during cache clearing") + question : qsTr("Are you sure you want to clear your local cache?", "displays during cache clearing") + note : qsTr("This will delete all of your stored preferences as well as cached email data for all accounts, temporarily slowing down the email download process significantly.", "displays during cache clearing") + answer : qsTr("Clearing the cache ...", "displays during cache clearing") + } + }, + State { + name: "checkUpdates" + PropertyChanges { + target: root + currentIndex : 1 + title : "" + question : "" + note : "" + answer : qsTr("Checking for updates ...", "displays if user clicks the Check for Updates button in the Help tab") + } + }, + State { + name: "addressmode" + PropertyChanges { + target: root + currentIndex : 0 + title : "" + question : qsTr("Do you want to continue?", "asked when the user changes between split and combined address mode") + note : qsTr("Changing between split and combined address mode will require you to delete your account(s) from your email client and begin the setup process from scratch.", "displayed when the user changes between split and combined address mode") + answer : qsTr("Configuring address mode...", "displayed when the user changes between split and combined address mode") + } + }, + State { + name: "toggleAutoStart" + PropertyChanges { + target: root + currentIndex : 1 + question : "" + note : "" + title : "" + answer : { + var msgTurnOn = qsTr("Turning on automatic start of Bridge...", "when the automatic start feature is selected") + var msgTurnOff = qsTr("Turning off automatic start of Bridge...", "when the automatic start feature is deselected") + return go.isAutoStart==false ? msgTurnOff : msgTurnOn + } + } + }, + State { + name: "toggleAllowProxy" + PropertyChanges { + target: root + currentIndex : 0 + question : { + var questionTurnOn = qsTr("Do you want to allow alternative routing?") + var questionTurnOff = qsTr("Do you want to disallow alternative routing?") + return go.isProxyAllowed==false ? questionTurnOn : questionTurnOff + } + note : qsTr("In case Proton sites are blocked, this setting allows Bridge to try alternative network routing to reach Proton, which can be useful for bypassing firewalls or network issues. We recommend keeping this setting on for greater reliability.") + title : { + var titleTurnOn = qsTr("Allow alternative routing") + var titleTurnOff = qsTr("Disallow alternative routing") + return go.isProxyAllowed==false ? titleTurnOn : titleTurnOff + } + answer : { + var msgTurnOn = qsTr("Allowing Bridge to use alternative routing to connect to Proton...", "when the allow proxy feature is selected") + var msgTurnOff = qsTr("Disallowing Bridge to use alternative routing to connect to Proton...", "when the allow proxy feature is deselected") + return go.isProxyAllowed==false ? msgTurnOn : msgTurnOff + } + } + }, + State { + name: "noKeychain" + PropertyChanges { + target: root + currentIndex : 0 + note : qsTr( + "%1 is not able to detected a supported password manager (pass, gnome-keyring). Please install and setup supported password manager and restart the application.", + "Error message when no keychain is detected" + ).arg(go.programTitle) + question : qsTr("Do you want to close application now?", "when no password manager found." ) + title : "No system password manager detected" + answer : qsTr("Closing Bridge...", "displayed when user is quitting application") + } + }, + State { + name: "undef"; + PropertyChanges { + target: root + currentIndex : 1 + question : "" + note : "" + title : "" + answer : "" + } + } + ] + + + Shortcut { + sequence: StandardKey.Cancel + onActivated: root.hide() + } + + Shortcut { + sequence: "Enter" + onActivated: root.confirmed() + } + + onHide: { + checkBoxWrapper.isChecked = false + state = "undef" + } + + onShow: { + // hide all other dialogs + winMain.dialogAddUser .visible = false + winMain.dialogChangePort .visible = false + winMain.dialogCredits .visible = false + winMain.dialogVersionInfo .visible = false + // dialogFirstStart should reappear again after closing global + root.visible = true + } + + + + onConfirmed : { + if (state == "quit" || state == "instance exists" ) { + timer.interval = 1000 + } else { + timer.interval = 300 + } + answ.forceActiveFocus() + timer.start() + } + + Connections { + target: timer + onTriggered: { + if ( state == "addressmode" ) { go.switchAddressMode (input) } + if ( state == "clearChain" ) { go.clearKeychain () } + if ( state == "clearCache" ) { go.clearCache () } + if ( state == "deleteUser" ) { go.deleteAccount (input, checkBoxWrapper.isChecked) } + if ( state == "logout" ) { go.logoutAccount (input) } + if ( state == "toggleAutoStart" ) { go.toggleAutoStart () } + if ( state == "toggleAllowProxy" ) { go.toggleAllowProxy () } + if ( state == "quit" ) { Qt.quit () } + if ( state == "instance exists" ) { Qt.quit () } + if ( state == "noKeychain" ) { Qt.quit () } + if ( state == "checkUpdates" ) { go.runCheckVersion (true) } + } + } + + Keys.onPressed: { + if (event.key == Qt.Key_Enter) { + root.confirmed() + } + } +} diff --git a/internal/frontend/qml/BridgeUI/HelpView.qml b/internal/frontend/qml/BridgeUI/HelpView.qml new file mode 100644 index 00000000..20d61ba7 --- /dev/null +++ b/internal/frontend/qml/BridgeUI/HelpView.qml @@ -0,0 +1,134 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// List the settings + +import QtQuick 2.8 +import BridgeUI 1.0 +import ProtonUI 1.0 + +Item { + id: root + + // must have wrapper + Rectangle { + id: wrapper + anchors.centerIn: parent + width: parent.width + height: parent.height + color: Style.main.background + + // content + Column { + anchors.horizontalCenter : parent.horizontalCenter + + ButtonIconText { + id: logs + anchors.left: parent.left + text: qsTr("Logs", "title of button that takes user to logs directory") + leftIcon.text : Style.fa.align_justify + rightIcon.text : Style.fa.chevron_circle_right + rightIcon.font.pointSize : Style.settings.toggleSize * Style.pt + onClicked: go.openLogs() + } + + ButtonIconText { + id: bugreport + anchors.left: parent.left + text: qsTr("Report Bug", "title of button that takes user to bug report form") + leftIcon.text : Style.fa.bug + rightIcon.text : Style.fa.chevron_circle_right + rightIcon.font.pointSize : Style.settings.toggleSize * Style.pt + onClicked: bugreportWin.show() + } + + ButtonIconText { + id: manual + anchors.left: parent.left + text: qsTr("Setup Guide", "title of button that opens setup and installation guide") + leftIcon.text : Style.fa.book + rightIcon.text : Style.fa.chevron_circle_right + rightIcon.font.pointSize : Style.settings.toggleSize * Style.pt + onClicked: go.openManual() + } + + ButtonIconText { + id: updates + anchors.left: parent.left + text: qsTr("Check for Updates", "title of button to check for any app updates") + leftIcon.text : Style.fa.refresh + rightIcon.text : Style.fa.chevron_circle_right + rightIcon.font.pointSize : Style.settings.toggleSize * Style.pt + onClicked: { + dialogGlobal.state="checkUpdates" + dialogGlobal.show() + dialogGlobal.confirmed() + } + } + + // Bottom version notes + Rectangle { + anchors.horizontalCenter : parent.horizontalCenter + height: viewAccount.separatorNoAccount - 3.2*manual.height + width: wrapper.width + color : "transparent" + AccessibleText { + anchors { + bottom: parent.bottom + horizontalCenter: parent.horizontalCenter + } + color: Style.main.textDisabled + horizontalAlignment: Qt.AlignHCenter + font.pointSize : Style.main.fontSize * Style.pt + text: + "ProtonMail Bridge "+go.getBackendVersion()+"\n"+ + "© 2020 Proton Technologies AG" + } + } + Row { + anchors.left : parent.left + + Rectangle { height: Style.dialog.spacing; width: (wrapper.width- credits.width - release.width - sepaCreditsRelease.width)/2; color: "transparent"} + + ClickIconText { + id:credits + iconText : "" + text : qsTr("Credits", "link to click on to view list of credited libraries") + textColor : Style.main.textDisabled + fontSize : Style.main.fontSize + textUnderline : true + onClicked : winMain.dialogCredits.show() + } + + Rectangle {id: sepaCreditsRelease ; height: Style.dialog.spacing; width: Style.main.dummy; color: "transparent"} + + ClickIconText { + id:release + iconText : "" + text : qsTr("Release notes", "link to click on to view release notes for this version of the app") + textColor : Style.main.textDisabled + fontSize : Style.main.fontSize + textUnderline : true + onClicked : { + go.getLocalVersionInfo() + winMain.dialogVersionInfo.show() + } + } + } + } + } +} diff --git a/internal/frontend/qml/BridgeUI/InfoWindow.qml b/internal/frontend/qml/BridgeUI/InfoWindow.qml new file mode 100644 index 00000000..ac0380aa --- /dev/null +++ b/internal/frontend/qml/BridgeUI/InfoWindow.qml @@ -0,0 +1,144 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Window for imap and smtp settings + +import QtQuick 2.8 +import QtQuick.Window 2.2 +import BridgeUI 1.0 +import ProtonUI 1.0 + + +Window { + id:root + width : Style.info.width + height : Style.info.height + minimumWidth : Style.info.width + minimumHeight : Style.info.height + maximumWidth : Style.info.width + maximumHeight : Style.info.height + color: "transparent" + flags : Qt.Window | Qt.Dialog | Qt.FramelessWindowHint + title : address + + Accessible.role: Accessible.Window + Accessible.name: qsTr("Configuration information for %1").arg(address) + Accessible.description: Accessible.name + + property QtObject accData : QtObject { // avoid null-pointer error + property string account : "undef" + property string aliases : "undef" + property string hostname : "undef" + property string password : "undef" + property int portIMAP : 0 + property int portSMTP : 0 + } + property string address : "undef" + property int indexAccount : 0 + property int indexAddress : 0 + + WindowTitleBar { + id: titleBar + window: root + } + + Rectangle { // background + color: Style.main.background + anchors { + left : parent.left + right : parent.right + top : titleBar.bottom + bottom : parent.bottom + } + border { + width: Style.main.border + color: Style.tabbar.background + } + } + + // info content + Column { + anchors { + left: parent.left + top: titleBar.bottom + leftMargin: Style.main.leftMargin + topMargin: Style.info.topMargin + } + width : root.width - Style.main.leftMargin - Style.main.rightMargin + + TextLabel { text: qsTr("IMAP SETTINGS", "title of the portion of the configuration screen that contains IMAP settings"); state: "heading" } + Rectangle { width: parent.width; height: Style.info.topMargin; color: "#00000000"} + Grid { + columns: 2 + rowSpacing: Style.main.fontSize + TextLabel { text: qsTr("Hostname", "in configuration screen, displays the server hostname (127.0.0.1)") + ":"} TextValue { text: root.accData.hostname } + TextLabel { text: qsTr("Port", "in configuration screen, displays the server port (ex. 1025)") + ":"} TextValue { text: root.accData.portIMAP } + TextLabel { text: qsTr("Username", "in configuration screen, displays the username to use with the desktop client") + ":"} TextValue { text: root.address } + TextLabel { text: qsTr("Password", "in configuration screen, displays the Bridge password to use with the desktop client") + ":"} TextValue { text: root.accData.password } + TextLabel { text: qsTr("Security", "in configuration screen, displays the IMAP security settings") + ":"} TextValue { text: "STARTTLS" } + } + Rectangle { width: Style.main.dummy; height: Style.main.fontSize; color: "#00000000"} + Rectangle { width: Style.main.dummy; height: Style.info.topMargin; color: "#00000000"} + + TextLabel { text: qsTr("SMTP SETTINGS", "title of the portion of the configuration screen that contains SMTP settings"); state: "heading" } + Rectangle { width: Style.main.dummy; height: Style.info.topMargin; color: "#00000000"} + Grid { + columns: 2 + rowSpacing: Style.main.fontSize + TextLabel { text: qsTr("Hostname", "in configuration screen, displays the server hostname (127.0.0.1)") + ":"} TextValue { text: root.accData.hostname } + TextLabel { text: qsTr("Port", "in configuration screen, displays the server port (ex. 1025)") + ":"} TextValue { text: root.accData.portSMTP } + TextLabel { text: qsTr("Username", "in configuration screen, displays the username to use with the desktop client") + ":"} TextValue { text: root.address } + TextLabel { text: qsTr("Password", "in configuration screen, displays the Bridge password to use with the desktop client") + ":"} TextValue { text: root.accData.password } + TextLabel { text: qsTr("Security", "in configuration screen, displays the SMTP security settings") + ":"} TextValue { text: go.isSMTPSTARTTLS() ? "STARTTLS" : "SSL" } + } + Rectangle { width: Style.main.dummy; height: Style.main.fontSize; color: "#00000000"} + Rectangle { width: Style.main.dummy; height: Style.info.topMargin; color: "#00000000"} + } + + // apple mail button + ButtonRounded{ + anchors { + horizontalCenter: parent.horizontalCenter + bottom: parent.bottom + bottomMargin: Style.info.topMargin + } + color_main : Style.main.textBlue + isOpaque: false + text: qsTr("Configure Apple Mail", "button on configuration screen to automatically configure Apple Mail") + height: Style.main.fontSize*2 + width: 2*parent.width/3 + onClicked: { + go.configureAppleMail(root.indexAccount, root.indexAddress) + } + visible: go.goos == "darwin" + } + + + function showInfo(iAccount, iAddress) { + root.indexAccount = iAccount + root.indexAddress = iAddress + root.accData = accountsModel.get(iAccount) + root.address = accData.aliases.split(";")[iAddress] + root.show() + root.raise() + root.requestActivate() + } + + function hide() { + root.visible = false + } +} diff --git a/internal/frontend/qml/BridgeUI/MainWindow.qml b/internal/frontend/qml/BridgeUI/MainWindow.qml new file mode 100644 index 00000000..23dac148 --- /dev/null +++ b/internal/frontend/qml/BridgeUI/MainWindow.qml @@ -0,0 +1,455 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// This is main window + +import QtQuick 2.8 +import QtQuick.Window 2.2 +import QtQuick.Controls 2.1 +import QtQuick.Layouts 1.3 +import BridgeUI 1.0 +import ProtonUI 1.0 + + +// Main Window +Window { + id: root + property alias tabbar : tabbar + property alias viewContent : viewContent + property alias viewAccount : viewAccount + property alias dialogAddUser : dialogAddUser + property alias dialogChangePort : dialogChangePort + property alias dialogCredits : dialogCredits + property alias dialogTlsCert : dialogTlsCert + property alias dialogUpdate : dialogUpdate + property alias dialogFirstStart : dialogFirstStart + property alias dialogGlobal : dialogGlobal + property alias dialogVersionInfo : dialogVersionInfo + property alias dialogConnectionTroubleshoot : dialogConnectionTroubleshoot + property alias bubbleNote : bubbleNote + property alias addAccountTip : addAccountTip + property alias updateState : infoBar.state + property alias tlsBarState : tlsBar.state + property int heightContent : height-titleBar.height + + // main window appeareance + width : Style.main.width + height : Style.main.height + flags : Qt.Window | Qt.FramelessWindowHint + color: go.goos=="windows" ? "black" : "transparent" + title: go.programTitle + minimumWidth: Style.main.width + minimumHeight: Style.main.height + maximumWidth: Style.main.width + + property bool isOutdateVersion : root.updateState == "forceUpdate" + + property bool activeContent : + !dialogAddUser .visible && + !dialogChangePort .visible && + !dialogCredits .visible && + !dialogTlsCert .visible && + !dialogUpdate .visible && + !dialogFirstStart .visible && + !dialogGlobal .visible && + !dialogVersionInfo .visible && + !bubbleNote .visible + + Accessible.role: Accessible.Grouping + Accessible.description: qsTr("Window %1").arg(title) + Accessible.name: Accessible.description + + + Component.onCompleted : { + gui.winMain = root + console.log("GraphicsInfo of", titleBar, + "api" , titleBar.GraphicsInfo.api , + "majorVersion" , titleBar.GraphicsInfo.majorVersion , + "minorVersion" , titleBar.GraphicsInfo.minorVersion , + "profile" , titleBar.GraphicsInfo.profile , + "renderableType" , titleBar.GraphicsInfo.renderableType , + "shaderCompilationType" , titleBar.GraphicsInfo.shaderCompilationType , + "shaderSourceType" , titleBar.GraphicsInfo.shaderSourceType , + "shaderType" , titleBar.GraphicsInfo.shaderType) + + tabbar.focusButton() + } + + WindowTitleBar { + id: titleBar + window: root + } + + Rectangle { + anchors { + top : titleBar.bottom + left : parent.left + right : parent.right + bottom : parent.bottom + } + color: Style.title.background + } + + TLSCertPinIssueBar { + id: tlsBar + anchors { + left : parent.left + right : parent.right + top : titleBar.bottom + leftMargin: Style.main.border + rightMargin: Style.main.border + } + enabled : root.activeContent + } + + InformationBar { + id: infoBar + anchors { + left : parent.left + right : parent.right + top : tlsBar.bottom + leftMargin: Style.main.border + rightMargin: Style.main.border + } + enabled : root.activeContent + } + + + TabLabels { + id: tabbar + currentIndex : 0 + enabled: root.activeContent + anchors { + top : infoBar.bottom + right : parent.right + left : parent.left + leftMargin: Style.main.border + rightMargin: Style.main.border + } + model: [ + { "title" : qsTr("Accounts" , "title of tab that shows account list" ), "iconText": Style.fa.user_circle_o }, + { "title" : qsTr("Settings" , "title of tab that allows user to change settings" ), "iconText": Style.fa.cog }, + { "title" : qsTr("Help" , "title of tab that shows the help menu" ), "iconText": Style.fa.life_ring } + ] + } + + // Content of tabs + StackLayout { + id: viewContent + enabled: root.activeContent + // dimensions + anchors { + left : parent.left + right : parent.right + top : tabbar.bottom + bottom : parent.bottom + leftMargin: Style.main.border + rightMargin: Style.main.border + bottomMargin: Style.main.border + } + // attributes + currentIndex : { return root.tabbar.currentIndex} + clip : true + // content + AccountView { + id: viewAccount + onAddAccount: dialogAddUser.show() + model: accountsModel + delegate: AccountDelegate { + row_width: viewContent.width + } + } + + SettingsView { id: viewSettings; } + HelpView { id: viewHelp; } + } + + + // Floating things + + // Triangle + Rectangle { + id: tabtriangle + visible: false + property int margin : Style.main.leftMargin+ Style.tabbar.widthButton/2 + anchors { + top : tabbar.bottom + left : tabbar.left + leftMargin : tabtriangle.margin - tabtriangle.width/2 + tabbar.currentIndex * tabbar.spacing + } + width: 2*Style.tabbar.heightTriangle + height: Style.tabbar.heightTriangle + color: "transparent" + Canvas { + anchors.fill: parent + onPaint: { + var ctx = getContext("2d") + ctx.fillStyle = Style.tabbar.background + ctx.moveTo(0 , 0) + ctx.lineTo(width/2, height) + ctx.lineTo(width , 0) + ctx.closePath() + ctx.fill() + } + } + } + + // Bubble prevent action + Rectangle { + anchors { + left: parent.left + right: parent.right + top: titleBar.bottom + bottom: parent.bottom + } + visible: bubbleNote.visible + color: "#aa222222" + MouseArea { + anchors.fill: parent + hoverEnabled: true + } + } + BubbleNote { + id : bubbleNote + visible : false + Component.onCompleted : { + bubbleNote.place(0) + } + } + + BubbleNote { + id:addAccountTip + anchors.topMargin: viewAccount.separatorNoAccount - 2*Style.main.fontSize + text : qsTr("Click here to start", "on first launch, this is displayed above the Add Account button to tell the user what to do first") + state: (go.isFirstStart && viewAccount.numAccounts==0 && root.viewContent.currentIndex==0) ? "Visible" : "Invisible" + bubbleColor: Style.main.textBlue + + Component.onCompleted : { + addAccountTip.place(-1) + } + enabled: false + + states: [ + State { + name: "Visible" + // hack: opacity 100% makes buttons dialog windows quit wrong color + PropertyChanges{target: addAccountTip; opacity: 0.999; visible: true} + }, + State { + name: "Invisible" + PropertyChanges{target: addAccountTip; opacity: 0.0; visible: false} + } + ] + + transitions: [ + Transition { + from: "Visible" + to: "Invisible" + + SequentialAnimation{ + NumberAnimation { + target: addAccountTip + property: "opacity" + duration: 0 + easing.type: Easing.InOutQuad + } + NumberAnimation { + target: addAccountTip + property: "visible" + duration: 0 + } + } + }, + Transition { + from: "Invisible" + to: "Visible" + SequentialAnimation{ + NumberAnimation { + target: addAccountTip + property: "visible" + duration: 300 + } + NumberAnimation { + target: addAccountTip + property: "opacity" + duration: 500 + easing.type: Easing.InOutQuad + } + } + } + ] + } + + // Dialogs + DialogFirstStart { + id: dialogFirstStart + visible: go.isFirstStart && gui.isFirstWindow && !dialogGlobal.visible + } + + // Dialogs + DialogPortChange { + id: dialogChangePort + } + + DialogConnectionTroubleshoot { + id: dialogConnectionTroubleshoot + } + + DialogAddUser { + id: dialogAddUser + onCreateAccount: Qt.openUrlExternally("https://protonmail.com/signup") + } + + DialogUpdate { + id: dialogUpdate + + property string manualLinks : { + var out = "" + var links = go.downloadLink.split("\n") + var l; + for (l in links) { + out += '%1
'.arg(links[l]) + } + return out + } + + title: root.isOutdateVersion ? + qsTr("%1 is outdated", "title of outdate dialog").arg(go.programTitle): + qsTr("%1 update to %2", "title of update dialog").arg(go.programTitle).arg(go.newversion) + introductionText: { + if (root.isOutdateVersion) { + if (go.goos=="linux") { + return qsTr('You are using an outdated version of our software.
+ Please download and install the latest version to continue using %1.

+ %2', + "Message for force-update in Linux").arg(go.programTitle).arg(dialogUpdate.manualLinks) + } else { + return qsTr('You are using an outdated version of our software.
+ Please download and install the latest version to continue using %1.

+ You can continue with the update or download and install the new version manually from

+ %2', + "Message for force-update in Win/Mac").arg(go.programTitle).arg(go.landingPage) + } + } else { + if (go.goos=="linux") { + return qsTr('A new version of Bridge is available.
+ Check release notes to learn what is new in %2.
+ Use your package manager to update or download and install the new version manually from

+ %3', + "Message for update in Linux").arg("releaseNotes").arg(go.newversion).arg(dialogUpdate.manualLinks) + } else { + return qsTr('A new version of Bridge is available.
+ Check release notes to learn what is new in %2.
+ You can continue with the update or download and install new version manually from

+ %3', + "Message for update in Win/Mac").arg("releaseNotes").arg(go.newversion).arg(go.landingPage) + } + } + } + } + + + Dialog { + id: dialogCredits + title: qsTr("Credits", "link to click on to view list of credited libraries") + Credits { } + } + + DialogTLSCertInfo { + id: dialogTlsCert + } + + Dialog { + id: dialogVersionInfo + property bool checkVersionOnClose : false + title: qsTr("Information about", "title of release notes page") + " v" + go.newversion + VersionInfo { } + onShow : { + // Hide information bar with old version + if (infoBar.state=="oldVersion") { + infoBar.state="upToDate" + dialogVersionInfo.checkVersionOnClose = true + } + } + onHide : { + // Reload current version based on online status + if (dialogVersionInfo.checkVersionOnClose) go.runCheckVersion(false) + dialogVersionInfo.checkVersionOnClose = false + } + } + + DialogYesNo { + id: dialogGlobal + question : "" + answer : "" + z: 100 + } + + + // resize + MouseArea { + property int diff: 0 + anchors { + bottom : parent.bottom + left : parent.left + right : parent.right + } + cursorShape: Qt.SizeVerCursor + height: Style.main.fontSize + onPressed: { + var globPos = mapToGlobal(mouse.x, mouse.y) + diff = root.height + diff -= globPos.y + } + onMouseYChanged : { + var globPos = mapToGlobal(mouse.x, mouse.y) + root.height = Math.max(root.minimumHeight, globPos.y + diff) + } + } + + function showAndRise(){ + go.loadAccounts() + root.show() + root.raise() + if (!root.active) { + root.requestActivate() + } + } + + // Toggle window + function toggle() { + go.loadAccounts() + if (root.visible) { + if (!root.active) { + root.raise() + root.requestActivate() + } else { + root.hide() + } + } else { + root.show() + root.raise() + } + } + + onClosing: { + close.accepted = false + // NOTE: In order to make an initial accounts load + root.hide() + gui.closeMainWindow() + } +} diff --git a/internal/frontend/qml/BridgeUI/ManualWindow.qml b/internal/frontend/qml/BridgeUI/ManualWindow.qml new file mode 100644 index 00000000..f8e90f0c --- /dev/null +++ b/internal/frontend/qml/BridgeUI/ManualWindow.qml @@ -0,0 +1,16 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . diff --git a/internal/frontend/qml/BridgeUI/OutgoingNoEncPopup.qml b/internal/frontend/qml/BridgeUI/OutgoingNoEncPopup.qml new file mode 100644 index 00000000..b2daec84 --- /dev/null +++ b/internal/frontend/qml/BridgeUI/OutgoingNoEncPopup.qml @@ -0,0 +1,148 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Popup + +import QtQuick 2.8 +import QtQuick.Window 2.2 +import BridgeUI 1.0 +import ProtonUI 1.0 + +Window { + id:root + width : Style.info.width + height : Style.info.width/1.5 + minimumWidth : Style.info.width + minimumHeight : Style.info.width/1.5 + maximumWidth : Style.info.width + maximumHeight : Style.info.width/1.5 + color : Style.main.background + flags : Qt.Window | Qt.Popup | Qt.FramelessWindowHint + visible : false + title : "" + x: 10 + y: 10 + property string messageID: "" + + // Drag and move + MouseArea { + property point diff: "0,0" + property QtObject window: root + + anchors { + fill: parent + } + + onPressed: { + diff = Qt.point(window.x, window.y) + var mousePos = mapToGlobal(mouse.x, mouse.y) + diff.x -= mousePos.x + diff.y -= mousePos.y + } + + onPositionChanged: { + var currPos = mapToGlobal(mouse.x, mouse.y) + window.x = currPos.x + diff.x + window.y = currPos.y + diff.y + } + } + + Column { + topPadding: Style.main.fontSize + spacing: (root.height - (description.height + cancel.height + countDown.height + Style.main.fontSize))/3 + width: root.width + + Text { + id: description + color : Style.main.text + font.pointSize : Style.main.fontSize*Style.pt/1.2 + anchors.horizontalCenter : parent.horizontalCenter + horizontalAlignment : Text.AlignHCenter + width : root.width - 2*Style.main.leftMargin + wrapMode : Text.Wrap + textFormat : Text.RichText + + text: qsTr("The message with subject %1 has one or more recipients with no encryption settings. If you do not want to send this email click the cancel button.").arg("

"+root.title+"

") + } + + Row { + spacing : Style.dialog.spacing + anchors.horizontalCenter: parent.horizontalCenter + + ButtonRounded { + id: cancel + onClicked : root.hide(true) + height: Style.main.fontSize*2 + //width: Style.dialog.widthButton*1.3 + fa_icon: Style.fa.send + text: qsTr("Send now", "Confirmation of sending unencrypted email.") + } + + ButtonRounded { + id: sendAnyway + onClicked : root.hide(false) + height: Style.main.fontSize*2 + //width: Style.dialog.widthButton*1.3 + fa_icon: Style.fa.times + text: qsTr("Cancel", "Cancel the sending of current email") + } + } + + Text { + id: countDown + color: Style.main.text + font.pointSize : Style.main.fontSize*Style.pt/1.2 + anchors.horizontalCenter : parent.horizontalCenter + horizontalAlignment : Text.AlignHCenter + width : root.width - 2*Style.main.leftMargin + wrapMode : Text.Wrap + textFormat : Text.RichText + + text: qsTr("This popup will close after %1 and email will be sent unless you click the cancel button.").arg( "" + timer.secLeft + "s") + } + } + + Timer { + id: timer + property var secLeft: 0 + interval: 1000 //ms + repeat: true + onTriggered: { + secLeft-- + if (secLeft <= 0) { + root.hide(true) + } + } + } + + function hide(shouldSend) { + root.visible = false + timer.stop() + go.saveOutgoingNoEncPopupCoord(root.x, root.y) + go.shouldSendAnswer(root.messageID, shouldSend) + } + + function show(messageID, subject) { + root.messageID = messageID + root.title = subject + root.visible = true + timer.secLeft = 10 + timer.start() + } +} + + diff --git a/internal/frontend/qml/BridgeUI/SettingsView.qml b/internal/frontend/qml/BridgeUI/SettingsView.qml new file mode 100644 index 00000000..ed4baa50 --- /dev/null +++ b/internal/frontend/qml/BridgeUI/SettingsView.qml @@ -0,0 +1,180 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// List the settings + +import QtQuick 2.8 +import BridgeUI 1.0 +import ProtonUI 1.0 +import QtQuick.Controls 2.4 + +Item { + id: root + + // must have wrapper + ScrollView { + id: wrapper + anchors.centerIn: parent + width: parent.width + height: parent.height + clip: true + background: Rectangle { + color: Style.main.background + } + + // content + Column { + anchors.left : parent.left + + ButtonIconText { + id: cacheClear + text: qsTr("Clear Cache", "button to clear cache in settings") + leftIcon.text : Style.fa.times + rightIcon { + text : qsTr("Clear", "clickable link next to clear cache button in settings") + color: Style.main.text + font { + pointSize : Style.settings.fontSize * Style.pt + underline : true + } + } + onClicked: { + dialogGlobal.state="clearCache" + dialogGlobal.show() + } + } + + ButtonIconText { + id: cacheKeychain + text: qsTr("Clear Keychain", "button to clear keychain in settings") + leftIcon.text : Style.fa.chain_broken + rightIcon { + text : qsTr("Clear", "clickable link next to clear keychain button in settings") + color: Style.main.text + font { + pointSize : Style.settings.fontSize * Style.pt + underline : true + } + } + onClicked: { + dialogGlobal.state="clearChain" + dialogGlobal.show() + } + } + + ButtonIconText { + id: autoStart + text: qsTr("Automatically start Bridge", "label for toggle that activates and disables the automatic start") + leftIcon.text : Style.fa.rocket + rightIcon { + font.pointSize : Style.settings.toggleSize * Style.pt + text : go.isAutoStart!=false ? Style.fa.toggle_on : Style.fa.toggle_off + color : go.isAutoStart!=false ? Style.main.textBlue : Style.main.textDisabled + } + Accessible.description: ( + go.isAutoStart == false ? + qsTr("Enable" , "Click to enable the automatic start of Bridge") : + qsTr("Disable" , "Click to disable the automatic start of Bridge") + ) + " " + text + onClicked: { + go.toggleAutoStart() + } + } + + ButtonIconText { + id: advancedSettings + property bool isAdvanced : !go.isDefaultPort + text: qsTr("Advanced settings", "button to open the advanced settings list in the settings page") + leftIcon.text : Style.fa.cogs + rightIcon { + font.pointSize : Style.settings.toggleSize * Style.pt + text : isAdvanced!=0 ? Style.fa.chevron_circle_up : Style.fa.chevron_circle_right + color : isAdvanced!=0 ? Style.main.textDisabled : Style.main.textBlue + } + + Accessible.description: ( + isAdvanced ? + qsTr("Hide", "Click to hide the advance settings") : + qsTr("Show", "Click to show the advance settings") + ) + " " + text + onClicked: { + isAdvanced = !isAdvanced + } + } + + ButtonIconText { + id: changePort + visible: advancedSettings.isAdvanced + text: qsTr("Change IMAP & SMTP settings", "button to change IMAP and SMTP ports in settings") + leftIcon.text : Style.fa.plug + rightIcon { + text : qsTr("Change", "clickable link next to change ports button in settings") + color: Style.main.text + font { + pointSize : Style.settings.fontSize * Style.pt + underline : true + } + } + onClicked: { + dialogChangePort.show() + } + } + + ButtonIconText { + id: reportNoEnc + text: qsTr("Notification of outgoing email without encryption", "Button to set whether to report or send an email without encryption") + visible: advancedSettings.isAdvanced + leftIcon.text : Style.fa.ban + rightIcon { + font.pointSize : Style.settings.toggleSize * Style.pt + text : go.isReportingOutgoingNoEnc ? Style.fa.toggle_on : Style.fa.toggle_off + color : go.isReportingOutgoingNoEnc ? Style.main.textBlue : Style.main.textDisabled + } + Accessible.description: ( + go.isReportingOutgoingNoEnc == 0 ? + qsTr("Enable" , "Click to report an email without encryption") : + qsTr("Disable" , "Click to send without asking an email without encryption") + ) + " " + text + onClicked: { + go.toggleIsReportingOutgoingNoEnc() + } + } + + ButtonIconText { + id: allowProxy + visible: advancedSettings.isAdvanced + text: qsTr("Allow alternative routing", "label for toggle that allows and disallows using a proxy") + leftIcon.text : Style.fa.rocket + rightIcon { + font.pointSize : Style.settings.toggleSize * Style.pt + text : go.isProxyAllowed!=false ? Style.fa.toggle_on : Style.fa.toggle_off + color : go.isProxyAllowed!=false ? Style.main.textBlue : Style.main.textDisabled + } + Accessible.description: ( + go.isProxyAllowed == false ? + qsTr("Enable" , "Click to allow alternative routing") : + qsTr("Disable" , "Click to disallow alternative routing") + ) + " " + text + onClicked: { + dialogGlobal.state="toggleAllowProxy" + dialogGlobal.show() + } + } + + } + } +} diff --git a/internal/frontend/qml/BridgeUI/StatusFooter.qml b/internal/frontend/qml/BridgeUI/StatusFooter.qml new file mode 100644 index 00000000..f8e90f0c --- /dev/null +++ b/internal/frontend/qml/BridgeUI/StatusFooter.qml @@ -0,0 +1,16 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . diff --git a/internal/frontend/qml/BridgeUI/VersionInfo.qml b/internal/frontend/qml/BridgeUI/VersionInfo.qml new file mode 100644 index 00000000..2451e8c5 --- /dev/null +++ b/internal/frontend/qml/BridgeUI/VersionInfo.qml @@ -0,0 +1,127 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// credits + +import QtQuick 2.8 +import BridgeUI 1.0 +import ProtonUI 1.0 + +Item { + Rectangle { + id: wrapper + anchors.centerIn: parent + width: 2*Style.main.width/3 + height: Style.main.height - 6*Style.dialog.titleSize + color: "transparent" + + Flickable { + anchors.fill : wrapper + contentWidth : wrapper.width + contentHeight : content.height + flickableDirection : Flickable.VerticalFlick + clip : true + + + Column { + id: content + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + width: wrapper.width + spacing: Style.dialog.spacing + + AccessibleText { + visible: go.changelog != "" + anchors { + left: parent.left + } + font.bold: true + font.pointSize: Style.main.fontSize * Style.pt + color: Style.main.text + text: qsTr("Release notes", "list of release notes for this version of the app") + ":" + } + + AccessibleSelectableText { + anchors { + left: parent.left + leftMargin: Style.main.leftMargin + } + font { + pointSize : Style.main.fontSize * Style.pt + } + width: wrapper.width - anchors.leftMargin + onLinkActivated: { + Qt.openUrlExternally(link) + } + wrapMode: Text.Wrap + color: Style.main.text + text: go.changelog + } + + AccessibleText { + visible: go.bugfixes != "" + anchors { + left: parent.left + } + font.bold: true + font.pointSize: Style.main.fontSize * Style.pt + color: Style.main.text + text: qsTr("Fixed bugs", "list of bugs fixed for this version of the app") + ":" + } + + AccessibleSelectableText { + visible: go.bugfixes!="" + anchors { + left: parent.left + leftMargin: Style.main.leftMargin + } + font { + pointSize : Style.main.fontSize * Style.pt + } + width: wrapper.width - anchors.leftMargin + onLinkActivated: { + Qt.openUrlExternally(link) + } + wrapMode: Text.Wrap + color: Style.main.text + text: go.bugfixes + } + + Rectangle{id:spacer; color:Style.transparent; width: Style.main.dummy; height: buttonClose.height} + + ButtonRounded { + id: buttonClose + anchors.horizontalCenter: content.horizontalCenter + text: qsTr("Close") + onClicked: { + dialogVersionInfo.hide() + } + } + + + AccessibleSelectableText { + anchors.horizontalCenter: content.horizontalCenter + font { + pointSize : Style.main.fontSize * Style.pt + } + color: Style.main.textDisabled + text: "\n Current: "+go.fullversion + } + } + } + } +} diff --git a/internal/frontend/qml/BridgeUI/qmldir b/internal/frontend/qml/BridgeUI/qmldir new file mode 100644 index 00000000..e7b9b66e --- /dev/null +++ b/internal/frontend/qml/BridgeUI/qmldir @@ -0,0 +1,15 @@ +module BridgeUI +AccountDelegate 1.0 AccountDelegate.qml +Credits 1.0 Credits.qml +DialogFirstStart 1.0 DialogFirstStart.qml +DialogPortChange 1.0 DialogPortChange.qml +DialogYesNo 1.0 DialogYesNo.qml +DialogTLSCertInfo 1.0 DialogTLSCertInfo.qml +HelpView 1.0 HelpView.qml +InfoWindow 1.0 InfoWindow.qml +MainWindow 1.0 MainWindow.qml +ManualWindow 1.0 ManualWindow.qml +OutgoingNoEncPopup 1.0 OutgoingNoEncPopup.qml +SettingsView 1.0 SettingsView.qml +StatusFooter 1.0 StatusFooter.qml +VersionInfo 1.0 VersionInfo.qml diff --git a/internal/frontend/qml/Gui.qml b/internal/frontend/qml/Gui.qml new file mode 100644 index 00000000..6e387ad3 --- /dev/null +++ b/internal/frontend/qml/Gui.qml @@ -0,0 +1,314 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// This is main qml file + +import QtQuick 2.8 +import BridgeUI 1.0 +import ProtonUI 1.0 + +// All imports from dynamic must be loaded before +import QtQuick.Window 2.2 +import QtQuick.Controls 2.1 +import QtQuick.Layouts 1.3 + +Item { + id: gui + property MainWindow winMain + property bool isFirstWindow: true + property int warningFlags: 0 + + InfoWindow { id: infoWin } + OutgoingNoEncPopup { id: outgoingNoEncPopup } + BugReportWindow { + id: bugreportWin + clientVersion.visible : true + + // pre-fill the form + onPrefill : { + userAddress.text="" + if (accountsModel.count>0) { + var addressList = accountsModel.get(0).aliases.split(";") + if (addressList.length>0) { + userAddress.text = addressList[0] + } + } + clientVersion.text=go.getLastMailClient() + } + } + + onWarningFlagsChanged : { + if (gui.warningFlags==Style.okInfoBar) { + go.normalSystray() + } else { + if ((gui.warningFlags & Style.errorInfoBar) == Style.errorInfoBar) { + go.errorSystray() + } else { + go.highlightSystray() + } + } + } + + // Signals from Go + Connections { + target: go + + onShowWindow : { + gui.openMainWindow() + } + onShowHelp : { + gui.openMainWindow(false) + winMain.tabbar.currentIndex = 2 + winMain.showAndRise() + } + onShowQuit : { + gui.openMainWindow(false) + winMain.dialogGlobal.state="quit" + winMain.dialogGlobal.show() + winMain.showAndRise() + } + + onProcessFinished : { + winMain.dialogGlobal.hide() + winMain.dialogAddUser.hide() + winMain.dialogChangePort.hide() + infoWin.hide() + } + onOpenManual : Qt.openUrlExternally("http://protonmail.com/bridge") + + onNotifyBubble : { + gui.showBubble(tabIndex, message, true) + } + onSilentBubble : { + gui.showBubble(tabIndex, message, false) + } + onBubbleClosed : { + gui.warningFlags &= ~Style.warnBubbleMessage + } + + onSetConnectionStatus: { + go.isConnectionOK = isAvailable + gui.openMainWindow(false) + if (go.isConnectionOK) { + if( winMain.updateState=="noInternet") { + go.setUpdateState("upToDate") + } + } else { + go.setUpdateState("noInternet") + } + } + + onRunCheckVersion : { + gui.openMainWindow(false) + go.setUpdateState("upToDate") + winMain.dialogGlobal.state="checkUpdates" + winMain.dialogGlobal.show() + go.isNewVersionAvailable(showMessage) + } + + onSetUpdateState : { + // once app is outdated prevent from state change + if (winMain.updateState != "forceUpdate") { + winMain.updateState = updateState + } + } + + onSetAddAccountWarning : winMain.dialogAddUser.setWarning(message, 0) + + + onNotifyVersionIsTheLatest : { + go.silentBubble(2,qsTr("You have the latest version!", "notification", -1)) + } + + onNotifyUpdate : { + go.setUpdateState("forceUpdate") + if (!winMain.dialogUpdate.visible) { + gui.openMainWindow(true) + go.runCheckVersion(false) + winMain.dialogUpdate.show() + } + } + + onNotifyLogout : { + go.notifyBubble(0, qsTr("Account %1 has been disconnected. Please log in to continue to use the Bridge with this account.").arg(accname) ) + } + + onNotifyAddressChanged : { + go.notifyBubble(0, qsTr("The address list has been changed for account %1. You may need to reconfigure the settings in your email client.").arg(accname) ) + } + + onNotifyAddressChangedLogout : { + go.notifyBubble(0, qsTr("The address list has been changed for account %1. You have to reconfigure the settings in your email client.").arg(accname) ) + } + + onNotifyPortIssue : { // busyPortIMAP , busyPortSMTP + if (!busyPortIMAP && !busyPortSMTP) { // at least one must have issues to show warning + return + } + gui.openMainWindow(false) + winMain.tabbar.currentIndex=1 + go.isDefaultPort = false + var text + if (busyPortIMAP && busyPortSMTP) { // both have problems + text = qsTr("The default ports used by Bridge for IMAP (%1) and SMTP (%2) are occupied by one or more other applications." , "the first part of notification text (two ports)").arg(go.getIMAPPort()).arg(go.getSMTPPort()) + text += " " + text += qsTr("To change the ports for these servers, go to Settings -> Advanced Settings.", "the second part of notification text (two ports)") + } else { // only one is occupied + var server, port + if (busyPortSMTP) { + server = "SMTP" + port = go.getSMTPPort() + } else { + server = "IMAP" + port = go.getIMAPPort() + } + text = qsTr("The default port used by Bridge for %1 (%2) is occupied by another application.", "the first part of notification text (one port)").arg(server).arg(port) + text += " " + text += qsTr("To change the port for this server, go to Settings -> Advanced Settings.", "the second part of notification text (one port)") + } + go.notifyBubble(1, text ) + } + + onNotifyKeychainRebuild : { + go.notifyBubble(1, qsTr( + "Your MacOS keychain is probably corrupted. Please consult the instructions in our FAQ.", + "notification message" + )) + } + + onNotifyHasNoKeychain : { + gui.winMain.dialogGlobal.state="noKeychain" + gui.winMain.dialogGlobal.show() + } + + onShowNoActiveKeyForRecipient : { + go.notifyBubble(0, qsTr( + "Key pinning is enabled for %1 but no active key is pinned. " + + "You must pin the key in order to send a message to this address. " + + "You can find instructions " + + "here." + ).arg(recipient)) + } + + onFailedAutostartCode : { + gui.openMainWindow(true) + switch (code) { + case "permission" : // linux+darwin + case "85070005" : // windows + go.notifyBubble(1, go.failedAutostartPerm) + break + case "81004003" : // windows + go.notifyBubble(1, go.failedAutostart+" "+qsTr("Can not create instance.", "for autostart")) + break + case "" : + default : + go.notifyBubble(1, go.failedAutostart) + } + } + + onShowOutgoingNoEncPopup : { + outgoingNoEncPopup.show(messageID, subject) + } + + onSetOutgoingNoEncPopupCoord : { + outgoingNoEncPopup.x = x + outgoingNoEncPopup.y = y + } + + onUpdateFinished : { + winMain.dialogUpdate.finished(hasError) + } + + onShowCertIssue : { + winMain.tlsBarState="notOK" + } + + } + + Timer { + id: checkVersionTimer + repeat : true + triggeredOnStart: false + interval : Style.main.verCheckRepeatTime + onTriggered : go.runCheckVersion(false) + } + + function openMainWindow(showAndRise) { + // wait and check until font is loaded + while(true){ + if (Style.fontawesome.status == FontLoader.Loading) continue + if (Style.fontawesome.status != FontLoader.Ready) console.log("Error while loading font") + break + } + + if (typeof(showAndRise)==='undefined') { + showAndRise = true + } + if (gui.winMain == null) { + gui.winMain = Qt.createQmlObject( + 'import BridgeUI 1.0; MainWindow {visible : false}', + gui, "winMain" + ) + } + if (showAndRise) { + gui.winMain.showAndRise() + } + } + + function closeMainWindow () { + gui.winMain.hide() + gui.winMain.destroy(5000) + gui.winMain = null + gui.isFirstWindow = false + } + + function showBubble(tabIndex, message, isWarning) { + gui.openMainWindow(true) + if (isWarning) { + gui.warningFlags |= Style.warnBubbleMessage + } + winMain.bubbleNote.text = message + winMain.bubbleNote.place(tabIndex) + winMain.bubbleNote.show() + } + + // On start + Component.onCompleted : { + // set messages for translations + go.wrongCredentials = qsTr("Incorrect username or password." , "notification", -1) + go.wrongMailboxPassword = qsTr("Incorrect mailbox password." , "notification", -1) + go.canNotReachAPI = qsTr("Cannot contact server, please check your internet connection." , "notification", -1) + go.versionCheckFailed = qsTr("Version check was unsuccessful. Please try again later." , "notification", -1) + go.credentialsNotRemoved = qsTr("Credentials could not be removed." , "notification", -1) + go.failedAutostartPerm = qsTr("Unable to configure automatic start due to permissions settings - see FAQ for details.", "notification", -1) + go.failedAutostart = qsTr("Unable to configure automatic start." , "notification", -1) + go.genericErrSeeLogs = qsTr("An error happened during procedure. See logs for more details." , "notification", -1) + + // start window + gui.openMainWindow(false) + checkVersionTimer.start() + if (go.isShownOnStart) { + gui.winMain.showAndRise() + } + go.runCheckVersion(false) + + if (go.isFreshVersion) { + go.getLocalVersionInfo() + gui.winMain.dialogVersionInfo.show() + } + } +} diff --git a/internal/frontend/qml/ProtonUI/AccessibleButton.qml b/internal/frontend/qml/ProtonUI/AccessibleButton.qml new file mode 100644 index 00000000..667cf276 --- /dev/null +++ b/internal/frontend/qml/ProtonUI/AccessibleButton.qml @@ -0,0 +1,34 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// default options to make button accessible + +import QtQuick 2.8 +import QtQuick.Controls 2.1 +import ProtonUI 1.0 + +Button { + function clearText(value) { + // remove font-awesome chars + return value.replace(/[\uf000-\uf2e0]/g,'') + } + Accessible.onPressAction: clicked() + Accessible.ignored: !enabled || !visible + Accessible.name: clearText(text) + Accessible.description: clearText(text) +} + diff --git a/internal/frontend/qml/ProtonUI/AccessibleSelectableText.qml b/internal/frontend/qml/ProtonUI/AccessibleSelectableText.qml new file mode 100644 index 00000000..61081f25 --- /dev/null +++ b/internal/frontend/qml/ProtonUI/AccessibleSelectableText.qml @@ -0,0 +1,40 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// default options to make text accessible and selectable + +import QtQuick 2.8 +import ProtonUI 1.0 + +TextEdit { + function clearText(value) { + // substitue the copyright symbol by the text and remove the font-awesome chars and HTML tags + return value.replace(/\uf1f9/g,'Copyright').replace(/[\uf000-\uf2e0]/g,'').replace(/<[^>]+>/g,'') + } + + readOnly: true + selectByKeyboard: true + selectByMouse: true + + Accessible.role: Accessible.StaticText + Accessible.name: clearText(text) + Accessible.description: clearText(text) + Accessible.focusable: true + Accessible.ignored: !enabled || !visible || text == "" +} + + diff --git a/internal/frontend/qml/ProtonUI/AccessibleText.qml b/internal/frontend/qml/ProtonUI/AccessibleText.qml new file mode 100644 index 00000000..0072fd78 --- /dev/null +++ b/internal/frontend/qml/ProtonUI/AccessibleText.qml @@ -0,0 +1,40 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// default options to make text accessible + +import QtQuick 2.8 +import ProtonUI 1.0 + +Text { + function clearText(value) { + // substitue the copyright symbol by the text and remove the font-awesome chars and HTML tags + return value.replace(/\uf1f9/g,'Copyright').replace(/[\uf000-\uf2e0]/g,'').replace(/<[^>]+>/g,'') + } + Accessible.role: Accessible.StaticText + Accessible.name: clearText(text) + Accessible.description: clearText(text) + Accessible.focusable: true + Accessible.ignored: !enabled || !visible || text == "" + + MouseArea { + anchors.fill: parent + cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor + acceptedButtons: Qt.NoButton + } +} + diff --git a/internal/frontend/qml/ProtonUI/AccountView.qml b/internal/frontend/qml/ProtonUI/AccountView.qml new file mode 100644 index 00000000..13a437a0 --- /dev/null +++ b/internal/frontend/qml/ProtonUI/AccountView.qml @@ -0,0 +1,140 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +import QtQuick 2.8 +import QtQuick.Controls 2.1 +import ProtonUI 1.0 + +Item { + id: root + + signal addAccount() + + property alias numAccounts : listAccounts.count + property alias model : listAccounts.model + property alias delegate : listAccounts.delegate + property int separatorNoAccount : viewContent.height-Style.accounts.heightFooter + property bool hasFooter : true + + // must have wrapper + Rectangle { + id: wrapper + anchors.centerIn: parent + width: parent.width + height: parent.height + color: Style.main.background + + // content + ListView { + id: listAccounts + anchors { + top : parent.top + left : parent.left + right : parent.right + bottom : hasFooter ? addAccFooter.top : parent.bottom + } + orientation: ListView.Vertical + clip: true + cacheBuffer: 2500 + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { + anchors { + right: parent.right + rightMargin: Style.main.rightMargin/4 + } + width: Style.main.rightMargin/3 + Accessible.ignored: true + } + header: Rectangle { + width : wrapper.width + height : root.numAccounts!=0 ? Style.accounts.heightHeader : root.separatorNoAccount + color : "transparent" + AccessibleText { // Placeholder on empty + anchors { + centerIn: parent + } + visible: root.numAccounts==0 + text : qsTr("No accounts added", "displayed when there are no accounts added") + font.pointSize : Style.main.fontSize * Style.pt + color : Style.main.textDisabled + } + Text { // Account + anchors { + left : parent.left + leftMargin : Style.main.leftMargin + verticalCenter : parent.verticalCenter + } + visible: root.numAccounts!=0 + font.bold : true + font.pointSize : Style.main.fontSize * Style.pt + text : qsTr("ACCOUNT", "title of column that displays account name") + color : Style.main.textDisabled + } + Text { // Status + anchors { + left : parent.left + leftMargin : Style.accounts.leftMargin2 + verticalCenter : parent.verticalCenter + } + visible: root.numAccounts!=0 + font.bold : true + font.pointSize : Style.main.fontSize * Style.pt + text : qsTr("STATUS", "title of column that displays connected or disconnected status") + color : Style.main.textDisabled + } + Text { // Actions + anchors { + left : parent.left + leftMargin : Style.accounts.leftMargin3 + verticalCenter : parent.verticalCenter + } + visible: root.numAccounts!=0 + font.bold : true + font.pointSize : Style.main.fontSize * Style.pt + text : qsTr("ACTIONS", "title of column that displays log out and log in actions for each account") + color : Style.main.textDisabled + } + // line + Rectangle { + anchors { + left : parent.left + right : parent.right + bottom : parent.bottom + } + visible: root.numAccounts!=0 + color: Style.accounts.line + height: Style.accounts.heightLine + } + } + } + + + AddAccountBar { + id: addAccFooter + visible: hasFooter + anchors { + left : parent.left + bottom : parent.bottom + } + } + } + + Shortcut { + sequence: StandardKey.SelectAll + onActivated: root.addAccount() + } +} diff --git a/internal/frontend/qml/ProtonUI/AddAccountBar.qml b/internal/frontend/qml/ProtonUI/AddAccountBar.qml new file mode 100644 index 00000000..0545ed8b --- /dev/null +++ b/internal/frontend/qml/ProtonUI/AddAccountBar.qml @@ -0,0 +1,69 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Bar with add account button and help + +import QtQuick 2.8 +import ProtonUI 1.0 + + +Rectangle { + width : parent.width + height : Style.accounts.heightFooter + color: "transparent" + Rectangle { + anchors { + top : parent.top + left : parent.left + right : parent.right + } + height: Style.accounts.heightLine + color: Style.accounts.line + } + ClickIconText { + id: buttonAddAccount + anchors { + left : parent.left + leftMargin : Style.main.leftMargin + verticalCenter : parent.verticalCenter + } + textColor : Style.main.textBlue + iconText : Style.fa.plus_circle + text : qsTr("Add Account", "begins the flow to log in to an account that is not yet listed") + textBold : true + onClicked : root.addAccount() + Accessible.description: { + if (gui.winMain!=null) { + return text + (gui.winMain.addAccountTip.visible? ", "+gui.winMain.addAccountTip.text : "") + } + return buttonAddAccount.text + } + } + ClickIconText { + id: buttonHelp + anchors { + right : parent.right + rightMargin : Style.main.rightMargin + verticalCenter : parent.verticalCenter + } + textColor : Style.main.textDisabled + iconText : Style.fa.question_circle + text : qsTr("Help", "directs the user to the online user guide") + textBold : true + onClicked : go.openManual() + } +} diff --git a/internal/frontend/qml/ProtonUI/BubbleNote.qml b/internal/frontend/qml/ProtonUI/BubbleNote.qml new file mode 100644 index 00000000..a6e79d85 --- /dev/null +++ b/internal/frontend/qml/ProtonUI/BubbleNote.qml @@ -0,0 +1,170 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Notify user + +import QtQuick 2.8 +import ProtonUI 1.0 + +Rectangle { + id: root + property int posx // x-coordinate of triangle + property bool isTriangleBelow + property string text + property alias bubbleColor: bubble.color + anchors { + top : tabbar.bottom + left : tabbar.left + leftMargin : { + // position of bubble calculated from posx + return Math.max( + Style.main.leftMargin, // keep minimal left margin + Math.min( + root.posx - root.width/2, // fit triangle in the middle if possible + tabbar.width - root.width - Style.main.rightMargin // keep minimal right margin + ) + ) + } + topMargin: 0 + } + height : triangle.height + bubble.height + width : bubble.width + color : "transparent" + visible : false + + + Rectangle { + id : triangle + anchors { + top : root.isTriangleBelow ? undefined : root.top + bottom : root.isTriangleBelow ? root.bottom : undefined + bottomMargin : 1*Style.px + left : root.left + leftMargin : root.posx - triangle.width/2 - root.anchors.leftMargin + } + width: 2*Style.tabbar.heightTriangle+2 + height: Style.tabbar.heightTriangle + color: "transparent" + Canvas { + anchors.fill: parent + rotation: root.isTriangleBelow ? 180 : 0 + onPaint: { + var ctx = getContext("2d") + ctx.fillStyle = bubble.color + ctx.moveTo(0 , height) + ctx.lineTo(width/2, 0) + ctx.lineTo(width , height) + ctx.closePath() + ctx.fill() + } + } + } + + Rectangle { + id: bubble + anchors { + top: root.top + left: root.left + topMargin: (root.isTriangleBelow ? 0 : triangle.height) + } + width : mainText.contentWidth + Style.main.leftMargin + Style.main.rightMargin + height : 2*Style.main.fontSize + radius : Style.bubble.radius + color : Style.bubble.background + + AccessibleText { + id: mainText + anchors { + horizontalCenter : parent.horizontalCenter + top: parent.top + topMargin : Style.main.fontSize + } + + text: ""+root.text+"" + width : Style.bubble.width - ( Style.main.leftMargin + Style.main.rightMargin ) + font.pointSize: Style.main.fontSize * Style.pt + horizontalAlignment: Text.AlignHCenter + textFormat: Text.RichText + wrapMode: Text.WordWrap + color: Style.bubble.text + onLinkActivated: { + Qt.openUrlExternally(link) + } + MouseArea { + anchors.fill: mainText + acceptedButtons: Qt.NoButton + } + + Accessible.name: qsTr("Message") + Accessible.description: root.text + } + + ButtonRounded { + id: okButton + visible: !root.isTriangleBelow + anchors { + bottom : parent.bottom + horizontalCenter : parent.horizontalCenter + bottomMargin : Style.main.fontSize + } + text: qsTr("Okay", "confirms and dismisses a notification") + height: Style.main.fontSize*2 + color_main: Style.main.text + color_minor: Style.main.textBlue + isOpaque: true + onClicked: hide() + } + } + + function place(index) { + if (index < 0) { + // add accounts + root.isTriangleBelow = true + bubble.height = 3.25*Style.main.fontSize + root.posx = 2*Style.main.leftMargin + bubble.width = mainText.contentWidth - Style.main.leftMargin + } else { + root.isTriangleBelow = false + bubble.height = ( + bubble.anchors.topMargin + // from top + mainText.contentHeight + // the text content + Style.main.fontSize + // gap between button + okButton.height + okButton.anchors.bottomMargin // from bottom and button + ) + if (index < 3) { + // possition accordig to top tab + var margin = Style.main.leftMargin + Style.tabbar.widthButton/2 + root.posx = margin + index*tabbar.spacing + } else { + // quit button + root.posx = tabbar.width - 2*Style.main.rightMargin + } + } + } + + function show() { + root.visible=true + gui.winMain.activeContent = false + } + + function hide() { + root.visible=false + go.bubbleClosed() + gui.winMain.activeContent = true + gui.winMain.tabbar.focusButton() + } +} diff --git a/internal/frontend/qml/ProtonUI/BugReportWindow.qml b/internal/frontend/qml/ProtonUI/BugReportWindow.qml new file mode 100644 index 00000000..e8857179 --- /dev/null +++ b/internal/frontend/qml/ProtonUI/BugReportWindow.qml @@ -0,0 +1,337 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Window for sending a bug report + +import QtQuick 2.8 +import QtQuick.Window 2.2 +import QtQuick.Controls 2.1 +import ProtonUI 1.0 +import QtGraphicalEffects 1.0 + +Window { + id:root + property alias userAddress : userAddress + property alias clientVersion : clientVersion + + width : Style.bugreport.width + height : Style.bugreport.height + minimumWidth : Style.bugreport.width + maximumWidth : Style.bugreport.width + minimumHeight : Style.bugreport.height + maximumHeight : Style.bugreport.height + + property color inputBorderColor : Style.main.text + + color : "transparent" + flags : Qt.Window | Qt.Dialog | Qt.FramelessWindowHint + title : "ProtonMail Bridge - Bug report" + visible : false + + WindowTitleBar { + id: titleBar + window: root + } + + Rectangle { + id:background + color: Style.main.background + anchors { + left : parent.left + right : parent.right + top : titleBar.bottom + bottom : parent.bottom + } + border { + width: Style.main.border + color: Style.tabbar.background + } + } + + Rectangle { + id:content + anchors { + fill : parent + leftMargin : Style.main.leftMargin + rightMargin : Style.main.rightMargin + bottomMargin : Style.main.rightMargin + topMargin : Style.main.rightMargin + titleBar.height + } + color: "transparent" + + // Description in flickable + Flickable { + id: descripWrapper + anchors { + left: parent.left + right: parent.right + top: parent.top + } + height: content.height - ( + (clientVersion.visible ? clientVersion.height + Style.dialog.fontSize : 0) + + userAddress.height + Style.dialog.fontSize + + securityNote.contentHeight + Style.dialog.fontSize + + cancelButton.height + Style.dialog.fontSize + ) + clip: true + contentWidth : width + contentHeight : height + + TextArea.flickable: TextArea { + id: description + focus: true + wrapMode: TextEdit.Wrap + placeholderText: qsTr ("Please briefly describe the bug(s) you have encountered...", "bug report instructions") + background : Rectangle { + color : Style.dialog.background + radius: Style.dialog.radiusButton + border { + color : root.inputBorderColor + width : Style.dialog.borderInput + } + + layer.enabled: true + layer.effect: FastBlur { + anchors.fill: parent + radius: 8 * Style.px + } + } + color: Style.main.text + font.pointSize: Style.dialog.fontSize * Style.pt + selectionColor: Style.main.textBlue + selectByKeyboard: true + selectByMouse: true + KeyNavigation.tab: clientVersion + KeyNavigation.priority: KeyNavigation.BeforeItem + } + + ScrollBar.vertical : ScrollBar{} + } + + // Client + TextLabel { + anchors { + left: parent.left + top: descripWrapper.bottom + topMargin: Style.dialog.fontSize + } + visible: clientVersion.visible + width: parent.width/2.618 + text: qsTr ("Email client:", "in the bug report form, which third-party email client is being used") + font.pointSize: Style.dialog.fontSize * Style.pt + } + + TextField { + id: clientVersion + anchors { + right: parent.right + top: descripWrapper.bottom + topMargin: Style.dialog.fontSize + } + placeholderText: qsTr("e.g. Thunderbird", "in the bug report form, placeholder text for email client") + width: parent.width/1.618 + + color : Style.dialog.text + selectionColor : Style.main.textBlue + selectByMouse : true + font.pointSize : Style.dialog.fontSize * Style.pt + padding : Style.dialog.radiusButton + + background: Rectangle { + color : Style.dialog.background + radius: Style.dialog.radiusButton + border { + color : root.inputBorderColor + width : Style.dialog.borderInput + } + + layer.enabled: true + layer.effect: FastBlur { + anchors.fill: parent + radius: 8 * Style.px + } + } + onAccepted: userAddress.focus = true + } + + // Address + TextLabel { + anchors { + left: parent.left + top: clientVersion.visible ? clientVersion.bottom : descripWrapper.bottom + topMargin: Style.dialog.fontSize + } + color: Style.dialog.text + width: parent.width/2.618 + text: qsTr ("Contact email:", "in the bug report form, an email to contact the user at") + font.pointSize: Style.dialog.fontSize * Style.pt + } + + TextField { + id: userAddress + anchors { + right: parent.right + top: clientVersion.visible ? clientVersion.bottom : descripWrapper.bottom + topMargin: Style.dialog.fontSize + } + placeholderText: "benjerry@protonmail.com" + width: parent.width/1.618 + + color : Style.dialog.text + selectionColor : Style.main.textBlue + selectByMouse : true + font.pointSize : Style.dialog.fontSize * Style.pt + padding : Style.dialog.radiusButton + + background: Rectangle { + color : Style.dialog.background + radius: Style.dialog.radiusButton + border { + color : root.inputBorderColor + width : Style.dialog.borderInput + } + + layer.enabled: true + layer.effect: FastBlur { + anchors.fill: parent + radius: 8 * Style.px + } + } + onAccepted: root.submit() + } + + // Note + AccessibleText { + id: securityNote + anchors { + left: parent.left + right: parent.right + top: userAddress.bottom + topMargin: Style.dialog.fontSize + } + wrapMode: Text.Wrap + color: Style.dialog.text + font.pointSize : Style.dialog.fontSize * Style.pt + text: + "" + Style.fa.exclamation_triangle + " " + + qsTr("Bug reports are not end-to-end encrypted!", "The first part of warning in bug report form") + " " + + qsTr("Please do not send any sensitive information.", "The second part of warning in bug report form") + " " + + qsTr("Contact us at security@protonmail.com for critical security issues.", "The third part of warning in bug report form") + } + + // buttons + ButtonRounded { + id: cancelButton + anchors { + left: parent.left + bottom: parent.bottom + } + fa_icon: Style.fa.times + text: qsTr ("Cancel", "dismisses current action") + onClicked : root.hide() + } + ButtonRounded { + anchors { + right: parent.right + bottom: parent.bottom + } + isOpaque: true + color_main: "white" + color_minor: Style.main.textBlue + fa_icon: Style.fa.send + text: qsTr ("Send", "button sends bug report") + onClicked : root.submit() + } + } + + Rectangle { + id: notification + property bool isOK: true + visible: false + color: background.color + anchors.fill: background + + Text { + anchors.centerIn: parent + color: Style.dialog.text + width: background.width*0.6180 + text: notification.isOK ? + qsTr ( "Bug report successfully sent." , "notification message about bug sending" ) : + qsTr ( "Unable to submit bug report." , "notification message about bug sending" ) + horizontalAlignment: Text.AlignHCenter + font.pointSize: Style.dialog.titleSize * Style.pt + } + + Timer { + id: notificationTimer + interval: 3000 + repeat: false + onTriggered : { + notification.visible=false + if (notification.isOK) root.hide() + } + } + } + + function submit(){ + if(root.areInputsOK()){ + root.notify(go.sendBug(description.text, clientVersion.text, userAddress.text )) + } + } + + function isEmpty(input){ + if (input.text=="") { + input.focus=true + input.placeholderText = qsTr("Field required", "a field that must be filled in to submit form") + return true + } + return false + } + + function areInputsOK() { + var isOK = true + if (isEmpty(userAddress)) { isOK=false } + if (clientVersion.visible && isEmpty(clientVersion)) { isOK=false } + if (isEmpty(description)) { isOK=false } + return isOK + } + + function clear() { + description.text = "" + clientVersion.text = "" + notification.visible = false + } + + signal prefill() + + function notify(isOK){ + notification.isOK = isOK + notification.visible = true + notificationTimer.start() + } + + + function show() { + prefill() + root.visible=true + } + + function hide() { + clear() + root.visible=false + } +} diff --git a/internal/frontend/qml/ProtonUI/ButtonIconText.qml b/internal/frontend/qml/ProtonUI/ButtonIconText.qml new file mode 100644 index 00000000..55cce3bd --- /dev/null +++ b/internal/frontend/qml/ProtonUI/ButtonIconText.qml @@ -0,0 +1,100 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Button with full window width containing two icons (left and right) and text + +import QtQuick 2.8 +import QtQuick.Controls 2.1 +import ProtonUI 1.0 + +AccessibleButton { + id: root + property alias leftIcon : leftIcon + property alias rightIcon : rightIcon + property alias main : mainText + + // dimensions + width : viewContent.width + height : Style.main.heightRow + topPadding: 0 + bottomPadding: 0 + leftPadding: Style.main.leftMargin + rightPadding: Style.main.rightMargin + + background : Rectangle{ + color: Qt.lighter(Style.main.background, root.hovered || root.activeFocus ? ( root.pressed ? 1.2: 1.1) :1.0) + // line + Rectangle { + anchors.bottom : parent.bottom + width : parent.width + height : Style.main.heightLine + color : Style.main.line + } + // pointing cursor + MouseArea { + anchors.fill : parent + cursorShape : Qt.PointingHandCursor + acceptedButtons: Qt.NoButton + } + } + + contentItem : Rectangle { + color: "transparent" + // Icon left + Text { + id: leftIcon + anchors { + verticalCenter : parent.verticalCenter + left : parent.left + } + font { + family : Style.fontawesome.name + pointSize : Style.settings.iconSize * Style.pt + } + color : Style.main.textBlue + text : Style.fa.hashtag + } + + // Icon/Text right + Text { + id: rightIcon + anchors { + verticalCenter : parent.verticalCenter + right : parent.right + } + font { + family : Style.fontawesome.name + pointSize : Style.settings.iconSize * Style.pt + } + color : Style.main.textBlue + text : Style.fa.hashtag + } + + // Label + Text { + id: mainText + anchors { + verticalCenter : parent.verticalCenter + left : leftIcon.right + leftMargin : leftIcon.text!="" ? Style.main.leftMargin : 0 + } + font.pointSize : Style.settings.fontSize * Style.pt + color : Style.main.text + text : root.text + } + } +} diff --git a/internal/frontend/qml/ProtonUI/ButtonRounded.qml b/internal/frontend/qml/ProtonUI/ButtonRounded.qml new file mode 100644 index 00000000..6dc6b3b0 --- /dev/null +++ b/internal/frontend/qml/ProtonUI/ButtonRounded.qml @@ -0,0 +1,92 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Classic button with icon and text + +import QtQuick 2.8 +import QtQuick.Controls 2.1 +import QtGraphicalEffects 1.0 +import ProtonUI 1.0 + +AccessibleButton { + id: root + property string fa_icon : "" + property color color_main : Style.dialog.text + property color color_minor : "transparent" + property bool isOpaque : false + + text : "undef" + state : root.hovered || root.activeFocus ? "hover" : "normal" + width : Style.dialog.widthButton + height : Style.dialog.heightButton + scale : root.pressed ? 0.96 : 1.00 + + background: Rectangle { + border { + color : root.color_main + width : root.isOpaque ? 0 : Style.dialog.borderButton + } + radius : Style.dialog.radiusButton + color : root.isOpaque ? root.color_minor : "transparent" + + MouseArea { + anchors.fill : parent + cursorShape : Qt.PointingHandCursor + acceptedButtons: Qt.NoButton + } + } + + contentItem: Rectangle { + color: "transparent" + + Row { + id: mainText + anchors.centerIn: parent + spacing: 0 + + Text { + font { + pointSize : Style.dialog.fontSize * Style.pt + family : Style.fontawesome.name + } + color : color_main + text : root.fa_icon=="" ? "" : root.fa_icon + " " + } + + Text { + font { + pointSize : Style.dialog.fontSize * Style.pt + } + color : color_main + text : root.text + } + } + + Glow { + id: mainTextEffect + anchors.fill : mainText + source: mainText + color: color_main + opacity: 0.33 + } + } + + states :[ + State {name: "normal"; PropertyChanges{ target: mainTextEffect; radius: 0 ; visible: false } }, + State {name: "hover" ; PropertyChanges{ target: mainTextEffect; radius: 3*Style.px ; visible: true } } + ] +} diff --git a/internal/frontend/qml/ProtonUI/CheckBoxLabel.qml b/internal/frontend/qml/ProtonUI/CheckBoxLabel.qml new file mode 100644 index 00000000..abfd800b --- /dev/null +++ b/internal/frontend/qml/ProtonUI/CheckBoxLabel.qml @@ -0,0 +1,55 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// input for date range + +import QtQuick 2.8 +import QtQuick.Controls 2.2 +import ProtonUI 1.0 + +CheckBox { + id: root + spacing: Style.dialog.spacing + padding: 0 + property color textColor : Style.main.text + property color checkedColor : Style.main.textBlue + property color uncheckedColor : Style.main.textInactive + property string checkedSymbol : Style.fa.check_square_o + property string uncheckedSymbol : Style.fa.square_o + background: Rectangle { + color: Style.transparent + } + indicator: Text { + text : root.checked ? root.checkedSymbol : root.uncheckedSymbol + color : root.checked ? root.checkedColor : root.uncheckedColor + font { + pointSize : Style.dialog.iconSize * Style.pt + family : Style.fontawesome.name + } + } + contentItem: Text { + id: label + text : root.text + color : root.textColor + font { + pointSize: Style.dialog.fontSize * Style.pt + } + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + leftPadding: Style.dialog.iconSize + root.spacing + } +} diff --git a/internal/frontend/qml/ProtonUI/ClickIconText.qml b/internal/frontend/qml/ProtonUI/ClickIconText.qml new file mode 100644 index 00000000..68861d6b --- /dev/null +++ b/internal/frontend/qml/ProtonUI/ClickIconText.qml @@ -0,0 +1,98 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// No border button with icon + +import QtQuick 2.8 +import QtQuick.Controls 2.1 +import ProtonUI 1.0 + +AccessibleButton { + id: root + + property string iconText : Style.fa.hashtag + property color textColor : Style.main.text + property int fontSize : Style.main.fontSize + property int iconSize : Style.main.iconSize + property int margin : iconText!="" ? Style.main.leftMarginButton : 0.0 + property bool iconOnRight : false + property bool textBold : false + property bool textUnderline : false + + + TextMetrics { + id: metrics + text: root.text + font: showText.font + } + + TextMetrics { + id: metricsIcon + text : root.iconText + font : showIcon.font + } + + scale : root.pressed ? 0.96 : root.activeFocus ? 1.05 : 1.0 + height : Math.max(metrics.height, metricsIcon.height) + width : metricsIcon.width*1.5 + margin + metrics.width + 4.0 + padding : 0.0 + + background : Rectangle { + color: Style.transparent + MouseArea { + anchors.fill : parent + cursorShape : Qt.PointingHandCursor + acceptedButtons: Qt.NoButton + } + } + + contentItem : Rectangle { + color: Style.transparent + Text { + id: showIcon + anchors { + left : iconOnRight ? showText.right : parent.left + leftMargin : iconOnRight ? margin : 0 + verticalCenter : parent.verticalCenter + } + font { + pointSize : iconSize * Style.pt + family : Style.fontawesome.name + } + color : textColor + text : root.iconText + } + + Text { + id: showText + anchors { + verticalCenter : parent.verticalCenter + left : iconOnRight ? parent.left : showIcon.right + leftMargin : iconOnRight ? 0 : margin + } + color : textColor + font { + pointSize : root.fontSize * Style.pt + bold: root.textBold + underline: root.textUnderline + } + text : root.text + } + } +} + + diff --git a/internal/frontend/qml/ProtonUI/Dialog.qml b/internal/frontend/qml/ProtonUI/Dialog.qml new file mode 100644 index 00000000..9e13a921 --- /dev/null +++ b/internal/frontend/qml/ProtonUI/Dialog.qml @@ -0,0 +1,147 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Dialog with adding new user + +import QtQuick 2.8 +import QtQuick.Layouts 1.3 +import ProtonUI 1.0 + + +StackLayout { + id: root + property string title : "title" + property string subtitle : "" + property alias timer : timer + property alias warning : warningText + property bool isDialogBusy : false + property real titleHeight : 2*titleText.anchors.topMargin + titleText.height + (warningText.visible ? warningText.anchors.topMargin + warningText.height : 0) + property Item background : Rectangle { + parent: root + width: root.width + height: root.height + color : Style.dialog.background + visible: root.visible + z: -1 + + AccessibleText { + id: titleText + anchors { + top: parent.top + horizontalCenter: parent.horizontalCenter + topMargin: Style.dialog.titleSize + } + font.pointSize : Style.dialog.titleSize * Style.pt + color : Style.dialog.text + text : root.title + } + + AccessibleText { + id: subtitleText + anchors { + top: titleText.bottom + horizontalCenter: parent.horizontalCenter + } + font.pointSize : Style.dialog.fontSize * Style.pt + color : Style.dialog.text + text : root.subtitle + visible : root.subtitle != "" + } + + AccessibleText { + id:warningText + anchors { + top: subtitleText.bottom + horizontalCenter: parent.horizontalCenter + } + font { + bold: true + pointSize: Style.dialog.fontSize * Style.pt + } + text : "" + color: Style.main.textBlue + visible: false + } + + // prevent any action below + MouseArea { + anchors.fill: parent + hoverEnabled: true + } + + ClickIconText { + anchors { + top: parent.top + right: parent.right + topMargin: Style.dialog.titleSize + rightMargin: Style.dialog.titleSize + } + visible : !isDialogBusy + iconText : Style.fa.times + text : "" + onClicked : root.hide() + Accessible.description : qsTr("Close dialog %1", "Click to exit modal.").arg(root.title) + } + } + + Accessible.role: Accessible.Grouping + Accessible.name: title + Accessible.description: title + Accessible.focusable: true + + + visible : false + anchors { + left : parent.left + right : parent.right + top : titleBar.bottom + bottom : parent.bottom + } + currentIndex : 0 + + + signal show() + signal hide() + + function incrementCurrentIndex() { + root.currentIndex++ + } + + function decrementCurrentIndex() { + root.currentIndex-- + } + + onShow: { + root.visible = true + root.forceActiveFocus() + } + + onHide: { + root.timer.stop() + root.currentIndex=0 + root.visible = false + root.timer.stop() + gui.winMain.tabbar.focusButton() + } + + // QTimer is recommeded solution for creating trheads : http://doc.qt.io/qt-5/qtquick-threading-example.html + Timer { + id: timer + interval: 300 // wait for transistion + repeat: false + } +} diff --git a/internal/frontend/qml/ProtonUI/DialogAddUser.qml b/internal/frontend/qml/ProtonUI/DialogAddUser.qml new file mode 100644 index 00000000..14fa3766 --- /dev/null +++ b/internal/frontend/qml/ProtonUI/DialogAddUser.qml @@ -0,0 +1,464 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Dialog with adding new user + +import QtQuick 2.8 +import ProtonUI 1.0 + + +Dialog { + id: root + + title : "" + + signal createAccount() + + property alias inputPassword : inputPassword + property alias input2FAuth : input2FAuth + property alias inputPasswMailbox : inputPasswMailbox + // + property alias username : inputUsername.text + property alias usernameElided : usernameMetrics.elidedText + + isDialogBusy : currentIndex==waitingAuthIndex || currentIndex==addingAccIndex + + property bool isFirstAccount: false + + property color buttonOpaqueMain : "white" + + + property int origin: 0 + property int nameAndPasswordIndex : 0 + property int waitingAuthIndex : 2 + property int twoFAIndex : 1 + property int mailboxIndex : 3 + property int addingAccIndex : 4 + property int newAccountIndex : 5 + + + signal cancel() + signal okay() + + TextMetrics { + id: usernameMetrics + font: dialogWaitingAuthText.font + elideWidth : Style.dialog.widthInput + elide : Qt.ElideMiddle + text : root.username + } + + Column { // 0 + id: dialogNameAndPassword + property int heightInputs : inputUsername.height + buttonRow.height + middleSep.height + inputPassword.height + middleSepPassw.height + + Rectangle { + id: topSep + color : "transparent" + width : Style.main.dummy + height : root.height/2 - (dialogNameAndPassword.heightInputs)/2 + } + + InputField { + id: inputUsername + iconText : Style.fa.user_circle + label : qsTr("Username", "enter username to add account") + onAccepted : inputPassword.focusInput = true + } + + Rectangle { id: middleSepPassw; color : "transparent"; width : Style.main.dummy; height : Style.dialog.heightSeparator} + + InputField { + id: inputPassword + label : qsTr("Password", "password entry field") + iconText : Style.fa.lock + isPassword : true + onAccepted : root.okay() + } + + Rectangle { id: middleSep; color : "transparent"; width : Style.main.dummy; height : 2*Style.dialog.heightSeparator } + + Row { + id: buttonRow + anchors.horizontalCenter: parent.horizontalCenter + spacing: Style.dialog.fontSize + ButtonRounded { + id:buttonCancel + fa_icon : Style.fa.times + text : qsTr("Cancel", "dismisses current action") + color_main : Style.dialog.text + onClicked : root.cancel() + } + ButtonRounded { + id: buttonNext + fa_icon : Style.fa.check + text : qsTr("Next", "navigate to next page in add account flow") + color_main : buttonOpaqueMain + color_minor : Style.dialog.textBlue + isOpaque : true + onClicked : root.okay() + } + } + + Rectangle { + color : "transparent" + width : Style.main.dummy + height : root.height - (topSep.height + dialogNameAndPassword.heightInputs + Style.main.bottomMargin + signUpForAccount.height) + } + + ClickIconText { + id: signUpForAccount + anchors.horizontalCenter: parent.horizontalCenter + fontSize : Style.dialog.fontSize + iconSize : Style.dialog.fontSize + iconText : "+" + text : qsTr ("Sign Up for an Account", "takes user to web page where they can create a ProtonMail account") + textBold : true + textUnderline : true + textColor : Style.dialog.text + onClicked : root.createAccount() + } + } + + Column { // 1 + id: dialog2FA + property int heightInputs : buttonRowPassw.height + middleSep2FA.height + input2FAuth.height + + Rectangle { + color : "transparent" + width : Style.main.dummy + height : (root.height - dialog2FA.heightInputs)/2 + } + + InputField { + id: input2FAuth + label : qsTr("Two Factor Code", "two factor code entry field") + iconText : Style.fa.lock + onAccepted : root.okay() + } + + Rectangle { id: middleSep2FA; color : "transparent"; width : Style.main.dummy; height : 2*Style.dialog.heightSeparator } + + Row { + id: buttonRowPassw + anchors.horizontalCenter: parent.horizontalCenter + spacing: Style.dialog.fontSize + ButtonRounded { + id: buttonBack + fa_icon: Style.fa.times + text: qsTr("Back", "navigate back in add account flow") + color_main: Style.dialog.text + onClicked : root.cancel() + } + ButtonRounded { + id: buttonNextTwo + fa_icon: Style.fa.check + text: qsTr("Next", "navigate to next page in add account flow") + color_main: buttonOpaqueMain + color_minor: Style.dialog.textBlue + isOpaque: true + onClicked : root.okay() + } + } + } + + Column { // 2 + id: dialogWaitingAuth + Rectangle { color : "transparent"; width : Style.main.dummy; height : (root.height-dialogWaitingAuthText.height) /2 } + Text { + id: dialogWaitingAuthText + anchors.horizontalCenter: parent.horizontalCenter + color: Style.dialog.text + font.pointSize: Style.dialog.fontSize * Style.pt + text : qsTr("Logging in") +"\n" + root.usernameElided + horizontalAlignment: Text.AlignHCenter + } + } + + Column { // 3 + id: dialogMailboxPassword + property int heightInputs : buttonRowMailbox.height + inputPasswMailbox.height + middleSepMailbox.height + + Rectangle { color : "transparent"; width : Style.main.dummy; height : (root.height - dialogMailboxPassword.heightInputs)/2} + + InputField { + id: inputPasswMailbox + label : qsTr("Mailbox password for %1", "mailbox password entry field").arg(root.usernameElided) + iconText : Style.fa.lock + isPassword : true + onAccepted : root.okay() + } + + Rectangle { id: middleSepMailbox; color : "transparent"; width : Style.main.dummy; height : 2*Style.dialog.heightSeparator } + + Row { + id: buttonRowMailbox + anchors.horizontalCenter: parent.horizontalCenter + spacing: Style.dialog.fontSize + ButtonRounded { + id: buttonBackBack + fa_icon: Style.fa.times + text: qsTr("Back", "navigate back in add account flow") + color_main: Style.dialog.text + onClicked : root.cancel() + } + ButtonRounded { + id: buttonLogin + fa_icon: Style.fa.check + text: qsTr("Next", "navigate to next page in add account flow") + color_main: buttonOpaqueMain + color_minor: Style.dialog.textBlue + isOpaque: true + onClicked : root.okay() + } + } + } + + Column { // 4 + id: dialogWaitingAccount + + Rectangle { color : "transparent"; width : Style.main.dummy; height : (root.height - dialogWaitingAccountText.height )/2 } + + Text { + id: dialogWaitingAccountText + anchors.horizontalCenter: parent.horizontalCenter + color: Style.dialog.text + font { + bold : true + pointSize: Style.dialog.fontSize * Style.pt + } + text : qsTr("Adding account, please wait ...", "displayed after user has logged in, before new account is displayed") + wrapMode: Text.Wrap + } + } + + Column { // 5 + id: dialogFirstUserAdded + + Rectangle { color : "transparent"; width : Style.main.dummy; height : (root.height - dialogWaitingAccountText.height - okButton.height*2 )/2 } + + Text { + id: textFirstUser + anchors.horizontalCenter: parent.horizontalCenter + color: Style.dialog.text + font { + bold : false + pointSize: Style.dialog.fontSize * Style.pt + } + width: 2*root.width/3 + horizontalAlignment: Text.AlignHCenter + textFormat: Text.RichText + text: ""+ + qsTr("Now you need to configure your client(s) to use the Bridge. Instructions for configuring your client can be found at", "") + + "
https://protonmail.com/bridge/clients." + wrapMode: Text.Wrap + onLinkActivated: { + Qt.openUrlExternally(link) + } + MouseArea { + anchors.fill: parent + cursorShape: parent.hoveredLink=="" ? Qt.PointingHandCursor : Qt.WaitCursor + acceptedButtons: Qt.NoButton + } + } + + Rectangle { color : "transparent"; width : Style.main.dummy; height : okButton.height} + + ButtonRounded{ + id: okButton + anchors.horizontalCenter: parent.horizontalCenter + color_main: buttonOpaqueMain + color_minor: Style.main.textBlue + isOpaque: true + text: qsTr("Okay", "confirms and dismisses a notification") + onClicked: root.hide() + } + } + + function clear_user() { + inputUsername.text = "" + inputUsername.rightIcon = "" + } + + function clear_passwd() { + inputPassword.text = "" + inputPassword.rightIcon = "" + inputPassword.hidePasswordText() + } + + + function clear_2fa() { + input2FAuth.text = "" + input2FAuth.rightIcon = "" + } + + function clear_passwd_mailbox() { + inputPasswMailbox.text = "" + inputPasswMailbox.rightIcon = "" + inputPasswMailbox.hidePasswordText() + } + + onCancel : { + root.warning.visible=false + if (currentIndex==0) { + root.hide() + } else { + clear_passwd() + clear_passwd_mailbox() + currentIndex=0 + } + } + + + function check_inputs() { + var isOK = true + switch (currentIndex) { + case nameAndPasswordIndex : + isOK &= inputUsername.checkNonEmpty() + isOK &= inputPassword.checkNonEmpty() + break + case twoFAIndex : + isOK &= input2FAuth.checkNonEmpty() + break + case mailboxIndex : + isOK &= inputPasswMailbox.checkNonEmpty() + break + } + if (isOK) { + warning.visible = false + warning.text= "" + } else { + setWarning(qsTr("Field required", "a field that must be filled in to submit form"),0) + } + return isOK + } + + function setWarning(msg, changeIndex) { + // show message + root.warning.text = msg + root.warning.visible = true + } + + + onOkay : { + var isOK = check_inputs() + if (isOK) { + root.origin = root.currentIndex + switch (root.currentIndex) { + case nameAndPasswordIndex: + case twoFAIndex: + root.currentIndex = waitingAuthIndex + break; + case mailboxIndex: + root.currentIndex = addingAccIndex + } + timer.start() + } + } + + onShow: { + root.title = qsTr ("Log in to your ProtonMail account", "displayed on screen when user enters username to begin adding account") + root.warning.visible = false + inputUsername.forceFocus() + root.isFirstAccount = go.isFirstStart && accountsModel.count==0 + } + + function startAgain() { + clear_passwd() + clear_2fa() + clear_passwd_mailbox() + root.currentIndex = nameAndPasswordIndex + root.inputPassword.focusInput = true + } + + function finishLogin(){ + root.currentIndex = addingAccIndex + var auth = go.addAccount(inputPasswMailbox.text) + if (auth<0) { + startAgain() + return + } + } + + Connections { + target: timer + + onTriggered : { + timer.repeat = false + switch (root.origin) { + case nameAndPasswordIndex: + var auth = go.login(inputUsername.text, inputPassword.text) + if (auth < 0) { + startAgain() + break + } + if (auth == 1) { + root.currentIndex = twoFAIndex + root.input2FAuth.focusInput = true + break + } + if (auth == 2) { + root.currentIndex = mailboxIndex + root.inputPasswMailbox.focusInput = true + break + } + root.inputPasswMailbox.text = inputPassword.text + root.finishLogin() + break; + case twoFAIndex: + var auth = go.auth2FA(input2FAuth.text) + if (auth < 0) { + startAgain() + break + } + if (auth == 1) { + root.currentIndex = mailboxIndex + root.inputPasswMailbox.focusInput = true + break + } + root.inputPasswMailbox.text = inputPassword.text + root.finishLogin() + break; + case mailboxIndex: + root.finishLogin() + break; + } + } + } + + onHide: { + // because hide slot is conneceted to processFinished it will update + // the list evertyime `go` obejcet is finished + clear_passwd() + clear_passwd_mailbox() + clear_2fa() + clear_user() + go.loadAccounts() + if (root.isFirstAccount && accountsModel.count==1) { + root.isFirstAccount=false + root.currentIndex=5 + root.show() + root.title=qsTr("Success, Account Added!", "shown after successful account addition") + } + } + + Keys.onPressed: { + if (event.key == Qt.Key_Enter) { + root.okay() + } + } +} diff --git a/internal/frontend/qml/ProtonUI/DialogConnectionTroubleshoot.qml b/internal/frontend/qml/ProtonUI/DialogConnectionTroubleshoot.qml new file mode 100644 index 00000000..4ade9c98 --- /dev/null +++ b/internal/frontend/qml/ProtonUI/DialogConnectionTroubleshoot.qml @@ -0,0 +1,148 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Dialog with Yes/No buttons + +import QtQuick 2.8 +import ProtonUI 1.0 + +Dialog { + id: root + + title : qsTr( + "Common connection problems and solutions", + "Title of the network troubleshooting modal" + ) + isDialogBusy: false // can close + property var parContent : [ + [ + qsTr("Allow alternative routing" , "Paragraph title"), + qsTr( + "In case Proton sites are blocked, this setting allows Bridge "+ + "to try alternative network routing to reach Proton, which can "+ + "be useful for bypassing firewalls or network issues. We recommend "+ + "keeping this setting on for greater reliability. "+ + 'Learn more'+ + " and "+ + 'enable here'+ + ".", + "Paragraph content" + ), + ], + + [ + qsTr("No internet connection" , "Paragraph title"), + qsTr( + "Please make sure that your internet connection is working.", + "Paragraph content" + ), + ], + + [ + qsTr("Internet Service Provider (ISP) problem" , "Paragraph title"), + qsTr( + "Try connecting to Proton from a different network (or use "+ + 'ProtonVPN'+ + " or "+ + 'Tor'+ + ").", + "Paragraph content" + ), + ], + + [ + qsTr("Government block" , "Paragraph title"), + qsTr( + "Your country may be blocking access to Proton. Try using "+ + 'ProtonVPN'+ + " (or any other VPN) or "+ + 'Tor'+ + ".", + "Paragraph content" + ), + ], + + [ + qsTr("Antivirus interference" , "Paragraph title"), + qsTr( + "Temporarily disable or remove your antivirus software.", + "Paragraph content" + ), + ], + + [ + qsTr("Proxy/Firewall interference" , "Paragraph title"), + qsTr( + "Disable any proxies or firewalls, or contact your network administrator.", + "Paragraph content" + ), + ], + + [ + qsTr("Still can’t find a solution" , "Paragraph title"), + qsTr( + "Contact us directly through our "+ + 'support form'+ + ", email (support@protonmail.com), or "+ + 'Twitter'+ + ".", + "Paragraph content" + ), + ], + + [ + qsTr("Proton is down" , "Paragraph title"), + qsTr( + "Check "+ + 'Proton Status'+ + " for our system status.", + "Paragraph content" + ), + ], + + ] + + Item { + AccessibleText { + anchors.centerIn: parent + color: Style.old.pm_white + linkColor: color + width: parent.width - 50 * Style.px + wrapMode: Text.WordWrap + font.pointSize: Style.main.fontSize*Style.pt + onLinkActivated: { + if (link=="showProxy") { + dialogGlobal.state= "toggleAllowProxy" + dialogGlobal.show() + } else { + Qt.openUrlExternally(link) + } + } + text: { + var content="" + for (var i=0; i " + content += par[1] + content += "

\n" + } + return content + } + } + } +} diff --git a/internal/frontend/qml/ProtonUI/DialogUpdate.qml b/internal/frontend/qml/ProtonUI/DialogUpdate.qml new file mode 100644 index 00000000..479de5f1 --- /dev/null +++ b/internal/frontend/qml/ProtonUI/DialogUpdate.qml @@ -0,0 +1,250 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// default options to make button accessible + +import QtQuick 2.8 +import QtQuick.Controls 2.2 +import ProtonUI 1.0 + + +Dialog { + id: root + + title: "Bridge update "+go.newversion + + property alias introductionText : introduction.text + property bool hasError : false + + signal cancel() + signal okay() + + + isDialogBusy: currentIndex==1 + + Rectangle { // 0: Release notes and confirm + width: parent.width + height: parent.height + color: Style.transparent + + Column { + anchors.centerIn: parent + spacing: 5*Style.dialog.spacing + + AccessibleText { + id:introduction + anchors.horizontalCenter: parent.horizontalCenter + color: Style.dialog.text + linkColor: Style.dialog.textBlue + font { + pointSize: 0.8 * Style.dialog.fontSize * Style.pt + } + width: 2*root.width/3 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + + // customize message per application + text: ' Release notes
New version %2


%3' + + onLinkActivated : { + console.log("clicked link:", link) + if (link == "releaseNotes"){ + root.hide() + winMain.dialogVersionInfo.show() + } else { + root.hide() + Qt.openUrlExternally(link) + } + } + + MouseArea { + anchors.fill: parent + cursorShape: introduction.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor + acceptedButtons: Qt.NoButton + } + } + + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: Style.dialog.spacing + + ButtonRounded { + fa_icon: Style.fa.times + text: (go.goos=="linux" ? qsTr("Okay") : qsTr("Cancel")) + color_main: Style.dialog.text + onClicked: root.cancel() + } + + ButtonRounded { + fa_icon: Style.fa.check + text: qsTr("Update") + visible: go.goos!="linux" + color_main: Style.dialog.text + color_minor: Style.main.textBlue + isOpaque: true + onClicked: root.okay() + } + } + } + } + + Rectangle { // 0: Check / download / unpack / prepare + id: updateStatus + width: parent.width + height: parent.height + color: Style.transparent + + Column { + anchors.centerIn: parent + spacing: Style.dialog.spacing + + AccessibleText { + color: Style.dialog.text + font { + pointSize: Style.dialog.fontSize * Style.pt + bold: false + } + width: 2*root.width/3 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + text: { + switch (go.progressDescription) { + case 1: return qsTr("Checking the current version.") + case 2: return qsTr("Downloading the update files.") + case 3: return qsTr("Verifying the update files.") + case 4: return qsTr("Unpacking the update files.") + case 5: return qsTr("Starting the update.") + case 6: return qsTr("Quitting the application.") + default: return "" + } + } + } + + ProgressBar { + id: progressbar + implicitWidth : 2*updateStatus.width/3 + implicitHeight : Style.exporting.rowHeight + visible: go.progress!=0 // hack hide animation when clearing out progress bar + value: go.progress + property int current: go.total * go.progress + property bool isFinished: finishedPartBar.width == progressbar.width + background: Rectangle { + radius : Style.exporting.boxRadius + color : Style.exporting.progressBackground + } + contentItem: Item { + Rectangle { + id: finishedPartBar + width : parent.width * progressbar.visualPosition + height : parent.height + radius : Style.exporting.boxRadius + gradient : Gradient { + GradientStop { position: 0.00; color: Qt.lighter(Style.main.textBlue,1.1) } + GradientStop { position: 0.66; color: Style.main.textBlue } + GradientStop { position: 1.00; color: Qt.darker(Style.main.textBlue,1.1) } + } + + Behavior on width { + NumberAnimation { duration:300; easing.type: Easing.InOutQuad } + } + } + Text { + anchors.centerIn: parent + text: "" + color: Style.main.background + font { + pointSize: Style.dialog.fontSize * Style.pt + } + } + } + } + } + } + + Rectangle { // 1: Something went wrong / All ok, closing bridge + width: parent.width + height: parent.height + color: Style.transparent + + Column { + anchors.centerIn: parent + spacing: 5*Style.dialog.spacing + + AccessibleText { + color: Style.dialog.text + linkColor: Style.dialog.textBlue + font { + pointSize: Style.dialog.fontSize * Style.pt + } + width: 2*root.width/3 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + text: !root.hasError ? qsTr('Application will quit now to finish the update.', "message after successful update") : + qsTr('The update procedure was not successful!
Please follow the download link and update manually.

%1').arg(go.downloadLink) + + onLinkActivated : { + console.log("clicked link:", link) + Qt.openUrlExternally(link) + } + + MouseArea { + anchors.fill: parent + cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor + acceptedButtons: Qt.NoButton + } + } + + ButtonRounded{ + anchors.horizontalCenter: parent.horizontalCenter + visible: root.hasError + text: qsTr("Close") + onClicked: root.cancel() + } + } + } + + function clear() { + root.hasError = false + go.progress = 0.0 + go.progressDescription = 0 + } + + function finished(hasError) { + root.hasError = hasError + root.incrementCurrentIndex() + } + + onShow: { + root.clear() + } + + onHide: { + root.clear() + } + + onOkay: { + switch (root.currentIndex) { + case 0: + go.startUpdate() + } + root.incrementCurrentIndex() + } + + onCancel: { + root.hide() + } +} diff --git a/internal/frontend/qml/ProtonUI/FileAndFolderSelect.qml b/internal/frontend/qml/ProtonUI/FileAndFolderSelect.qml new file mode 100644 index 00000000..ce73c6f4 --- /dev/null +++ b/internal/frontend/qml/ProtonUI/FileAndFolderSelect.qml @@ -0,0 +1,78 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// one line input text field with label +import QtQuick 2.8 +import QtQuick.Controls 2.2 +import QtQuick.Dialogs 1.0 +import ProtonUI 1.0 + +Row { + id: root + spacing: Style.dialog.spacing + + property string title : "title" + + property alias path: inputPath.text + property alias inputPath: inputPath + property alias dialogVisible: pathDialog.visible + + InputBox { + id: inputPath + anchors { + bottom: parent.bottom + } + spacing: Style.dialog.spacing + field { + height: browseButton.height + width: root.width - root.spacing - browseButton.width + } + + label: title + Component.onCompleted: sanitizePath(pathDialog.shortcuts.home) + } + + ButtonRounded { + id: browseButton + anchors { + bottom: parent.bottom + } + height: Style.dialog.heightInput + color_main: Style.main.textBlue + fa_icon: Style.fa.folder_open + text: qsTr("Browse", "click to look through directory for a file or folder") + onClicked: pathDialog.visible = true + } + + FileDialog { + id: pathDialog + title: root.title + ":" + folder: shortcuts.home + onAccepted: sanitizePath(pathDialog.fileUrl.toString()) + selectFolder: true + } + + function sanitizePath(path) { + var pattern = "file://" + if (go.goos=="windows") pattern+="/" + inputPath.text = path.replace(pattern, "") + } + + function checkNonEmpty() { + return inputPath.text != "" + } +} diff --git a/internal/frontend/qml/ProtonUI/InfoToolTip.qml b/internal/frontend/qml/ProtonUI/InfoToolTip.qml new file mode 100644 index 00000000..25f58e25 --- /dev/null +++ b/internal/frontend/qml/ProtonUI/InfoToolTip.qml @@ -0,0 +1,101 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// on hover information + +import QtQuick 2.8 +import QtQuick.Controls 2.2 +import ProtonUI 1.0 + +Text { // info icon + id:root + property alias info : tip.text + font { + family: Style.fontawesome.name + pointSize : Style.dialog.iconSize * Style.pt + } + text: Style.fa.info_circle + color: Style.main.textDisabled + + MouseArea { + anchors.fill: parent + hoverEnabled: true + + onEntered : tip.visible=true + onExited : tip.visible=false + } + + ToolTip { + id: tip + width: Style.bubble.width + x: - 0.2*tip.width + y: - tip.height + + topPadding : Style.main.fontSize/2 + bottomPadding : Style.main.fontSize/2 + leftPadding : Style.bubble.widthPane + Style.dialog.spacing + rightPadding: Style.dialog.spacing + delay: 800 + + background : Rectangle { + id: bck + color: Style.bubble.paneBackground + radius : Style.bubble.radius + + + Text { + id: icon + color: Style.bubble.background + text: Style.fa.info_circle + font { + family : Style.fontawesome.name + pointSize : Style.dialog.iconSize * Style.pt + } + anchors { + verticalCenter : bck.verticalCenter + left : bck.left + leftMargin : (Style.bubble.widthPane - icon.width) / 2 + } + } + + Rectangle { // right edge + anchors { + fill : bck + leftMargin : Style.bubble.widthPane + } + radius: parent.radius + color: Style.bubble.background + } + + Rectangle { // center background + anchors { + fill : parent + leftMargin : Style.bubble.widthPane + rightMargin : Style.bubble.widthPane + } + color: Style.bubble.background + } + } + + contentItem : Text { + text: tip.text + color: Style.bubble.text + wrapMode: Text.Wrap + font.pointSize: Style.main.fontSize * Style.pt + } + } +} diff --git a/internal/frontend/qml/ProtonUI/InformationBar.qml b/internal/frontend/qml/ProtonUI/InformationBar.qml new file mode 100644 index 00000000..b7ffdcf0 --- /dev/null +++ b/internal/frontend/qml/ProtonUI/InformationBar.qml @@ -0,0 +1,233 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Important information under title bar + +import QtQuick 2.8 +import QtQuick.Window 2.2 +import QtQuick.Controls 2.1 +import ProtonUI 1.0 + +Rectangle { + id: root + property var iTry: 0 + property var secLeft: 0 + property var second: 1000 // convert millisecond to second + property var checkInterval: [ 5, 10, 30, 60, 120, 300, 600 ] // seconds + property bool isVisible: true + property var fontSize : 1.2 * Style.main.fontSize + color : "black" + state: "upToDate" + + Timer { + id: retryInternet + interval: second + triggeredOnStart: false + repeat: true + onTriggered : { + secLeft-- + if (secLeft <= 0) { + retryInternet.stop() + go.checkInternet() + if (iTry < checkInterval.length-1) { + iTry++ + } + secLeft=checkInterval[iTry] + retryInternet.start() + } + } + } + + Row { + anchors.centerIn: root + visible: root.isVisible + spacing: Style.main.leftMarginButton + + AccessibleText { + id: message + font.pointSize: root.fontSize * Style.pt + } + + ClickIconText { + anchors.verticalCenter : message.verticalCenter + text : "("+go.newversion+" " + qsTr("release notes", "display the release notes from the new version")+")" + visible : root.state=="oldVersion" && ( go.changelog!="" || go.bugfixes!="") + iconText : "" + onClicked : { + dialogVersionInfo.show() + } + fontSize : root.fontSize + } + + ClickIconText { + anchors.verticalCenter : message.verticalCenter + text : root.state=="oldVersion" || root.state == "forceUpdate" ? + qsTr("Update", "click to update to a new version when one is available") : + qsTr("Retry now", "click to try to connect to the internet when the app is disconnected from the internet") + visible : root.state!="internetCheck" + iconText : "" + onClicked : { + if (root.state=="oldVersion" || root.state=="forceUpdate" ) { + winMain.dialogUpdate.show() + } else { + go.checkInternet() + } + } + fontSize : root.fontSize + textUnderline: true + } + Text { + anchors.baseline : message.baseline + color: Style.main.text + font { + pointSize : root.fontSize * Style.pt + bold : true + } + visible: root.state=="oldVersion" || root.state=="noInternet" + text : "|" + } + ClickIconText { + anchors.verticalCenter : message.verticalCenter + iconText : "" + text : root.state == "noInternet" ? + qsTr("Troubleshoot", "Show modal screen with additional tips for troubleshooting connection issues") : + qsTr("Remind me later", "Do not install new version and dismiss a notification") + visible : root.state=="oldVersion" || root.state=="noInternet" + onClicked : { + if (root.state == "oldVersion") { + root.state = "upToDate" + } + if (root.state == "noInternet") { + dialogConnectionTroubleshoot.show() + } + } + fontSize : root.fontSize + textUnderline: true + } + } + + onStateChanged : { + switch (root.state) { + case "forceUpdate" : + gui.warningFlags |= Style.errorInfoBar + break; + case "upToDate" : + gui.warningFlags &= ~Style.warnInfoBar + iTry = 0 + secLeft=checkInterval[iTry] + break; + case "noInternet" : + gui.warningFlags |= Style.warnInfoBar + retryInternet.start() + secLeft=checkInterval[iTry] + break; + default : + gui.warningFlags |= Style.warnInfoBar + } + + if (root.state!="noInternet") { + retryInternet.stop() + } + } + + function timeToRetry() { + if (secLeft==1){ + return qsTr("a second", "time to wait till internet connection is retried") + } else if (secLeft<60){ + return secLeft + " " + qsTr("seconds", "time to wait till internet connection is retried") + } else { + var leading = ""+secLeft%60 + if (leading.length < 2) { + leading = "0" + leading + } + return Math.floor(secLeft/60) + ":" + leading + } + } + + states: [ + State { + name: "internetCheck" + PropertyChanges { + target: root + height: 2* Style.main.fontSize + isVisible: true + color: Style.main.textOrange + } + PropertyChanges { + target: message + color: Style.main.background + text: qsTr("Checking connection. Please wait...", "displayed after user retries internet connection") + } + }, + State { + name: "noInternet" + PropertyChanges { + target: root + height: 2* Style.main.fontSize + isVisible: true + color: Style.main.textRed + } + PropertyChanges { + target: message + color: Style.main.line + text: qsTr("Cannot contact server. Retrying in ", "displayed when the app is disconnected from the internet or server has problems")+timeToRetry()+"." + } + }, + State { + name: "oldVersion" + PropertyChanges { + target: root + height: 2* Style.main.fontSize + isVisible: true + color: Style.main.textBlue + } + PropertyChanges { + target: message + color: Style.main.background + text: qsTr("An update is available.", "displayed in a notification when an app update is available") + } + }, + State { + name: "forceUpdate" + PropertyChanges { + target: root + height: 2* Style.main.fontSize + isVisible: true + color: Style.main.textRed + } + PropertyChanges { + target: message + color: Style.main.line + text: qsTr("%1 is outdated.", "displayed in a notification when app is outdated").arg(go.programTitle) + } + }, + State { + name: "upToDate" + PropertyChanges { + target: root + height: 0 + isVisible: false + color: Style.main.textBlue + } + PropertyChanges { + target: message + color: Style.main.background + text: "" + } + } + ] +} diff --git a/internal/frontend/qml/ProtonUI/InputBox.qml b/internal/frontend/qml/ProtonUI/InputBox.qml new file mode 100644 index 00000000..e699e480 --- /dev/null +++ b/internal/frontend/qml/ProtonUI/InputBox.qml @@ -0,0 +1,78 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// one line input text field with label +import QtQuick 2.8 +import QtQuick.Controls 2.2 +import QtQuick.Controls.Styles 1.4 +import ProtonUI 1.0 +import QtGraphicalEffects 1.0 + +Column { + id: root + property alias label: textlabel.text + property alias placeholderText: inputField.placeholderText + property alias echoMode: inputField.echoMode + property alias text: inputField.text + property alias field: inputField + + signal accepted() + + spacing: Style.dialog.heightSeparator + + Text { + id: textlabel + font { + pointSize: Style.dialog.fontSize * Style.pt + bold: true + } + color: Style.dialog.text + } + + + TextField { + id: inputField + width: Style.dialog.widthInput + height: Style.dialog.heightButton + selectByMouse : true + selectionColor : Style.main.textBlue + padding : Style.dialog.radiusButton + color : Style.dialog.text + font { + pointSize : Style.dialog.fontSize * Style.pt + family : Style.fontawesome.name + } + background: Rectangle { + color : Style.dialog.background + radius: Style.dialog.radiusButton + border { + color : Style.dialog.line + width : Style.dialog.borderInput + } + layer.enabled: true + layer.effect: FastBlur { + anchors.fill: parent + radius: 8 * Style.px + } + } + } + + Connections { + target : inputField + onAccepted : root.accepted() + } +} diff --git a/internal/frontend/qml/ProtonUI/InputField.qml b/internal/frontend/qml/ProtonUI/InputField.qml new file mode 100644 index 00000000..525fad39 --- /dev/null +++ b/internal/frontend/qml/ProtonUI/InputField.qml @@ -0,0 +1,172 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// one line input text field with label + +import QtQuick 2.8 +import QtQuick.Controls 2.1 +import ProtonUI 1.0 + +Column { + id: root + property alias focusInput : inputField.focus + property alias label : textlabel.text + property alias iconText : iconInput.text + property alias placeholderText : inputField.placeholderText + property alias text : inputField.text + property bool isPassword : false + property string rightIcon : "" + + signal accepted() + signal editingFinished() + + spacing: Style.dialog.heightSeparator + anchors.horizontalCenter : parent.horizontalCenter + + AccessibleText { + id: textlabel + anchors.left : parent.left + font { + pointSize : Style.dialog.fontSize * Style.pt + bold : true + } + horizontalAlignment: Text.AlignHCenter + color : Style.dialog.text + } + + Rectangle { + id: inputWrap + anchors.horizontalCenter : parent.horizontalCenter + width : Style.dialog.widthInput + height : Style.dialog.heightInput + color : "transparent" + + Text { + id: iconInput + anchors { + top : parent.top + left : parent.left + } + color : Style.dialog.text + font { + pointSize : Style.dialog.iconSize * Style.pt + family : Style.fontawesome.name + } + text: "o" + } + + TextField { + id: inputField + anchors { + fill: inputWrap + leftMargin : Style.dialog.iconSize+Style.dialog.fontSize + bottomMargin : inputWrap.height - Style.dialog.iconSize + } + verticalAlignment : TextInput.AlignTop + horizontalAlignment : TextInput.AlignLeft + selectByMouse : true + color : Style.dialog.text + selectionColor : Style.main.textBlue + font { + pointSize : Style.dialog.fontSize * Style.pt + family : Style.fontawesome.name + } + padding: 0 + background: Rectangle { + anchors.fill: parent + color : "transparent" + } + Component.onCompleted : { + if (isPassword) { + echoMode = TextInput.Password + } else { + echoMode = TextInput.Normal + } + } + + Accessible.name: textlabel.text + Accessible.description: textlabel.text + } + + Text { + id: iconRight + anchors { + top : parent.top + right : parent.right + } + color : Style.dialog.text + font { + pointSize : Style.dialog.iconSize * Style.pt + family : Style.fontawesome.name + } + text: ( !isPassword ? "" : ( + inputField.echoMode == TextInput.Password ? Style.fa.eye : Style.fa.eye_slash + )) + " " + rightIcon + MouseArea { + anchors.fill: parent + onClicked: { + if (isPassword) { + if (inputField.echoMode == TextInput.Password) inputField.echoMode = TextInput.Normal + else inputField.echoMode = TextInput.Password + } + } + } + } + + Rectangle { + anchors { + left : parent.left + right : parent.right + bottom : parent.bottom + } + height: Math.max(Style.main.border,1) + color: Style.dialog.text + } + } + + function checkNonEmpty() { + if (inputField.text == "") { + rightIcon = Style.fa.exclamation_triangle + root.placeholderText = "" + inputField.focus = true + return false + } else { + rightIcon = Style.fa.check_circle + } + return true + } + + function hidePasswordText() { + if (root.isPassword) inputField.echoMode = TextInput.Password + } + + function forceFocus() { + inputField.forceActiveFocus() + } + + Connections { + target: inputField + onAccepted: root.accepted() + onEditingFinished: root.editingFinished() + } + + Keys.onPressed: { + if (event.key == Qt.Key_Enter) { + root.accepted() + } + } +} diff --git a/internal/frontend/qml/ProtonUI/InstanceExistsWindow.qml b/internal/frontend/qml/ProtonUI/InstanceExistsWindow.qml new file mode 100644 index 00000000..b1bb8261 --- /dev/null +++ b/internal/frontend/qml/ProtonUI/InstanceExistsWindow.qml @@ -0,0 +1,79 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// This is main window + +import QtQuick 2.8 +import QtQuick.Window 2.2 +import ProtonUI 1.0 + + +// Main Window +Window { + id:winMain + + // main window appeareance + width : Style.main.width + height : Style.main.height + flags : Qt.Window | Qt.Dialog + title: qsTr("ProtonMail Bridge", "app title") + color : Style.main.background + visible : true + + Text { + id: title + anchors { + horizontalCenter: parent.horizontalCenter + top: parent.top + topMargin: Style.main.topMargin + } + font{ + pointSize: Style.dialog.titleSize * Style.pt + } + color: Style.main.text + text: + "" + Style.fa.exclamation_triangle + " " + + qsTr ("Warning: Instance exists", "displayed when a version of the app is opened while another is already running") + } + + Text { + id: message + anchors.centerIn : parent + horizontalAlignment: Text.AlignHCenter + font.pointSize: Style.dialog.fontSize * Style.pt + color: Style.main.text + width: 2*parent.width/3 + wrapMode: Text.Wrap + text: qsTr("An instance of the ProtonMail Bridge is already running.", "displayed when a version of the app is opened while another is already running") + " " + + qsTr("Please close the existing ProtonMail Bridge process before starting a new one.", "displayed when a version of the app is opened while another is already running")+ " " + + qsTr("This program will close now.", "displayed when a version of the app is opened while another is already running") + } + + ButtonRounded { + anchors { + horizontalCenter: parent.horizontalCenter + bottom: parent.bottom + bottomMargin: Style.main.bottomMargin + } + text: qsTr("Okay", "confirms and dismisses a notification") + color_main: Style.dialog.text + color_minor: Style.main.textBlue + isOpaque: true + onClicked: Qt.quit() + } +} + diff --git a/internal/frontend/qml/ProtonUI/LogoHeader.qml b/internal/frontend/qml/ProtonUI/LogoHeader.qml new file mode 100644 index 00000000..de01ab30 --- /dev/null +++ b/internal/frontend/qml/ProtonUI/LogoHeader.qml @@ -0,0 +1,150 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Header of window with logo and buttons + +import QtQuick 2.8 +import ProtonUI 1.0 +import QtQuick.Window 2.2 + + +Rectangle { + id: root + // dimensions + property Window parentWin + property string title: "ProtonMail Bridge" + property bool hasIcon : true + anchors.top : parent.top + anchors.right : parent.right + width : Style.main.width + height : Style.title.height + // style + color : Style.title.background + + signal hideClicked() + + // Drag to move : https://stackoverflow.com/a/18927884 + MouseArea { + property variant clickPos: "1,1" + anchors.fill: parent + onPressed: { + clickPos = Qt.point(mouse.x,mouse.y) + } + onPositionChanged: { + var delta = Qt.point(mouse.x-clickPos.x, mouse.y-clickPos.y) + parentWin.x += delta.x; + parentWin.y += delta.y; + } + } + + // logo + Image { + id: imgLogo + height : Style.title.imgHeight + fillMode : Image.PreserveAspectFit + visible: root.hasIcon + anchors { + left : root.left + leftMargin : Style.title.leftMargin + verticalCenter : root.verticalCenter + } + //source : "qrc://logo.svg" + source : "logo.svg" + smooth : true + } + + TextMetrics { + id: titleMetrics + elideWidth: 2*root.width/3 + elide: Qt.ElideMiddle + font: titleText.font + text: root.title + } + + // Title + Text { + id: titleText + anchors { + left : hasIcon ? imgLogo.right : parent.left + leftMargin : hasIcon ? Style.title.leftMargin : Style.main.leftMargin + verticalCenter : root.verticalCenter + } + text : titleMetrics.elidedText + color : Style.title.text + font.pointSize : Style.title.fontSize * Style.pt + } + + // Underline Button + Rectangle { + id: buttonUndrLine + anchors { + verticalCenter : root.verticalCenter + right : buttonCross.left + rightMargin : 2*Style.title.fontSize + } + width : Style.title.fontSize + height : Style.title.fontSize + color : "transparent" + Canvas { + anchors.fill: parent + onPaint: { + var val = Style.title.fontSize + var ctx = getContext("2d") + ctx.strokeStyle = 'white' + ctx.strokeWidth = 4 + ctx.moveTo(0 , val-1) + ctx.lineTo(val, val-1) + ctx.stroke() + } + } + MouseArea { + anchors.fill: parent + onClicked: root.hideClicked() + } + } + + // Cross Button + Rectangle { + id: buttonCross + anchors { + verticalCenter : root.verticalCenter + right : root.right + rightMargin : Style.main.rightMargin + } + width : Style.title.fontSize + height : Style.title.fontSize + color : "transparent" + Canvas { + anchors.fill: parent + onPaint: { + var val = Style.title.fontSize + var ctx = getContext("2d") + ctx.strokeStyle = 'white' + ctx.strokeWidth = 4 + ctx.moveTo(0,0) + ctx.lineTo(val,val) + ctx.moveTo(val,0) + ctx.lineTo(0,val) + ctx.stroke() + } + } + MouseArea { + anchors.fill: parent + onClicked: root.hideClicked() + } + } +} diff --git a/internal/frontend/qml/ProtonUI/PopupMessage.qml b/internal/frontend/qml/ProtonUI/PopupMessage.qml new file mode 100644 index 00000000..67aa1b36 --- /dev/null +++ b/internal/frontend/qml/ProtonUI/PopupMessage.qml @@ -0,0 +1,81 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Popup message +import QtQuick 2.8 +import QtQuick.Controls 2.2 +import ProtonUI 1.0 + +Rectangle { + id: root + color: Style.transparent + property alias text: message.text + visible: false + + MouseArea { // prevent action below + anchors.fill: parent + hoverEnabled: true + } + + Rectangle { + id: backgroundInp + anchors.centerIn : root + color : Style.errorDialog.background + radius : Style.errorDialog.radius + width : parent.width/3. + height : contentInp.height + + Column { + id: contentInp + anchors.horizontalCenter: backgroundInp.horizontalCenter + spacing: Style.dialog.heightSeparator + topPadding: Style.dialog.heightSeparator + bottomPadding: Style.dialog.heightSeparator + + AccessibleText { + id: message + font { + pointSize : Style.errorDialog.fontSize * Style.pt + bold : true + } + color: Style.errorDialog.text + horizontalAlignment: Text.AlignHCenter + width : backgroundInp.width - 2*Style.main.rightMargin + wrapMode: Text.Wrap + } + + ButtonRounded { + text : qsTr("Okay", "todo") + isOpaque : true + color_main : Style.dialog.background + color_minor : Style.dialog.textBlue + onClicked : root.hide() + anchors.horizontalCenter : parent.horizontalCenter + } + } + } + + function show(text) { + root.text = text + root.visible = true + } + + function hide() { + root.state = "Okay" + root.visible=false + } +} diff --git a/internal/frontend/qml/ProtonUI/ProgressBar.qml b/internal/frontend/qml/ProtonUI/ProgressBar.qml new file mode 100644 index 00000000..f8e90f0c --- /dev/null +++ b/internal/frontend/qml/ProtonUI/ProgressBar.qml @@ -0,0 +1,16 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . diff --git a/internal/frontend/qml/ProtonUI/Style.qml b/internal/frontend/qml/ProtonUI/Style.qml new file mode 100644 index 00000000..07655ba1 --- /dev/null +++ b/internal/frontend/qml/ProtonUI/Style.qml @@ -0,0 +1,1089 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// colors, fonts, etc. +pragma Singleton +import QtQuick 2.8 + +QtObject { + // Colors, dimensions and font + + property TextMetrics oneInch : TextMetrics { // 72 points is one inch + id: oneInch + font.pointSize: 72 + text: "HI" + } + property real dpi : oneInch.height // one inch in pixel + property real refdpi : 96.0 + property real px : dpi/refdpi // default unit, scaled to current DPI + property real pt : 80 / dpi /// conversion from px to pt (aestetic correction of font size +3 points) + + property color transparent: "transparent" + + //=================// + // Stelian // + //=================// + // Main window + property QtObject main : QtObject { + property color background : "#353440" + property color text : "#ffffff" + property color textInactive : "#cdcaf7" + property color textDisabled : "#bbbbbb" + property color textBlue : "#9199cc" + property color textRed : "#ef6a5e" + property color textGreen : "#41a56c" + property color textOrange : "#e6922e" + property color line : "#44444f" + property real dummy : 10 * px + property real width : 650 * px + property real height : 420 * px + property real heightRow : 54 * px + property real heightLine : 2 * px + property real leftMargin : 17 * px + property real rightMargin : 17 * px + property real systrayMargin : 20 * px + property real fontSize : 12 * px + property real iconSize : 15 * px + property real leftMarginButton : 9 * px + property real verCheckRepeatTime : 15*60*60*1000 // milliseconds + property real topMargin : fontSize + property real bottomMargin : fontSize + property real border : 1 * px + } + + property QtObject bugreport : QtObject { + property real width : 645 * px + property real height : 495 * px + } + + property QtObject errorDialog : QtObject { + property color background : bubble.background + property color text : bubble.text + property real fontSize : dialog.fontSize + property real radius : 10 * px + } + + property QtObject dialog : QtObject { + property color background : "#ee353440" + property color text : "#ffffff" + property color line : "#505061" + property color textBlue : "#9396cc" + property color shadow : "#19505061" + property real fontSize : 14 * px + property real titleSize : 17 * px + property real iconSize : 18 * px + property real widthButton : 100 * px + property real heightButton : 3*fontSize + property real radiusButton : 5 * px + property real borderButton : 2 * px + property real borderInput : 1.2 * px + property real heightSeparator : 21 * px + property real widthInput : 280 * px + property real heightInput : 30 * px + property real heightButtonIcon : 150 * px + property real widthButtonIcon : 160 * px + property real rightMargin : 17 * px + property real bottomMargin : 17 * px + property real topMargin : 17 * px + property real leftMargin : 17 * px + property real heightInputBox : 4*fontSize + property real widthInputBox : 280 * px + property real spacing : 5 * px + } + + // Title specific + property QtObject title : QtObject { + property color background : "#000" + property color text : main.text + property real height : 26 * px + property real leftMargin : 10 * px + property real fontSize : 14 * px + } + + property QtObject titleMacOS : QtObject { + property color background : tabbar.background + property real height : 22 * px + property real imgHeight : 12 * px + property real leftMargin : 8 * px + property real fontSize : 14 * px + property real radius : 7 * px + } + + // Tabline specific + property QtObject tabbar : QtObject { + property color background : "#302f3a" + property color text : "#ffffff" + property color textInactive : "#9696a7" + property real height : 68 * px + property real widthButton : 63 * px + property real heightButton : 38 * px + property real spacingButton : 35 * px + property real heightTriangle : 7 * px + property real fontSize : 12 * px + property real iconSize : 17 * px + property real bottomMargin : (height-heightButton)/2 + property real widthUpdate : 138 * px + property real heightUpdate : 40 * px + property real leftMargin : main.leftMargin + property string rightButton : "quit" + } + + // Bubble specific + property QtObject bubble: QtObject { + property color background : "#454553" + property color paneBackground : background + property color text : dialog.text + property real height : 185 * px + property real width : 220 * px + property real radius : 5 * px + property real widthPane : 20 * px + property real iconSize : 14 * px + property real fontSize : main.fontSize + } + + + property QtObject menu : QtObject { + property color background : "#454553" + property color line : "#505061" + property color lineAlt : "#565668" + property color text : "#ffffff" + property real width : 184 * px + property real height : 200 * px + property real radius : 7 * px + property real topMargin : 21*px + tabbar.height + property real rightMargin : 24 * px + //property real heightLine : (width - 2*radius) / 3 + } + + property QtObject accounts : QtObject { + property color line : main.line + property color backgroundExpanded : "#444456" + property color backgroundAddrRow : "#1cffffff" + property real heightLine : main.heightLine + property real heightHeader : 38 * px + property real heightAccount : main.heightRow + property real heightFooter : 75 * px + + property real elideWidth : 285 * px + property real leftMargin2 : 334 * px + property real leftMargin3 : 463 * px + property real leftMargin4 : 567 * px + property real sizeChevron : 9 * px + + property real heightAddrRow : 39 * px + property real heightAddr : 32 * px + property real leftMarginAddr : 28 * px + } + + property QtObject settings : QtObject { + property real fontSize : 15 * px + property real iconSize : 20 * px + property real toggleSize : 28 * px + } + + property QtObject info : QtObject { + property real width : 315 * px + property real height : 450 * px + property real heightHeader : 32 * px + property real topMargin : 18 * px + property real iconSize : 16 * px + property real leftMarginIcon : 8 * px + property real widthValue : 180 * px + } + + property QtObject exporting : QtObject { + property color background : dialog.background + property color rowBackground : "#f8f8f8" + property color sliderBackground : "#e6e6e6" + property color sliderForeground : "#515061" + property color line : dialog.line + property color text : "#333" + property color progressBackground : "#aaa" + property color progressStatus : main.textBlue // need gradient +- 1.2 light + property real boxRadius : 5 * px + property real rowHeight : 32 * px + property real leftMargin : 5 * px + property real leftMargin2 : 18 * px + property real leftMargin3 : 30 * px + } + + property int okInfoBar : 0 + property int warnInfoBar : 1 + property int warnBubbleMessage : 2 + property int errorInfoBar : 4 + + + + // old color pick + property QtObject old : QtObject { + /// old scheme + property color darkBackground: "grey" + property color darkForground: "red" + property color darkInactive: "grey" + property color lightBackground: "white" + property color lightForground: "grey" + property color lightInactive: "grey" + property color linkColor: "blue" + property color warnColor: "orange" + // + property color pm_black: "#000" // pitch black + property color pm_ddgrey: "#333" // dark background + property color pm_dgrey: "#555" // + property color pm_grey: "#999" // inactive dark, lines + property color pm_white: "#fff" // super white + property color pm_lgrey: "#acb0bf" // inactive light + property color pm_llgrey: "#e6eaf0" // light background + property color pm_blue: "#8286c5" + property color pm_lblue: "#9397cd" + property color pm_llblue: "#e3e4f2" + property color pm_dred: "#c26164" + property color pm_red: "#af4649" + property color pm_orange: "#d9c4a2" // warning + property color pm_lorange: "#e7d360" // warning + property color pm_green: "#a6cc93" // success + + property color web_dark_side_back : "#333333" + property color web_dark_side_text_inactive : "#999999" + property color web_dark_highl_back : "#3F3F3F" + property color web_dark_highl_text : "#FFFFFF" + property color web_dark_highl_icon : "#A8ABD7" + property color web_dark_top_back : "#555555" + property color web_dark_top_icon : "#E9E9E9" + property color web_butt_blue_back : "#9397CD" + property color web_butt_blue_text : "#FFFFFF" + property color web_row_inactive_back : "#DDDDDD" + property color web_row_inactive_text : "#55556E" + property color web_butt_grey_text_line : "#838897" + property color web_butt_grey_text_hover : "#222222" + property color web_butt_grey_top_back : "#FDFDFD" + property color web_butt_grey_low_back : "#DEDEDE" + property color web_main_back : "#FFFFFF" + property color web_main_text : "#555555" + + + // colors + property color pmold_dblue: "#333367" + property color pmold_blue: "#58588c" + property color pmold_red: "#9e3c3c" + property color pmold_orange: "#9e7f3c" + property color pmold_green: "#5e9162" + property color pmold_gray: "#484a61" + // highlited + //property color highlBackground: pm_lblue + //property color highlForground: pm_dgrey + //property color highlInactive: pm_grey + //property color buttLine: pm_grey + property color buttLight: "#fdfdfd" // button background start + property color buttDark: "#dedede" // button background start + } + + + // font + property FontLoader fontawesome : FontLoader { + //source: "qrc://fontawesome.ttf" + source: "fontawesome.ttf" + } + + property QtObject fa : QtObject { + property string glass : "\uf000" + property string music : "\uf001" + property string search : "\uf002" + property string envelope_o : "\uf003" + property string heart : "\uf004" + property string star : "\uf005" + property string star_o : "\uf006" + property string user : "\uf007" + property string film : "\uf008" + property string th_large : "\uf009" + property string th : "\uf00a" + property string th_list : "\uf00b" + property string check : "\uf00c" + property string remove : "\uf00d" + property string close : "\uf00d" + property string times : "\uf00d" + property string search_plus : "\uf00e" + property string search_minus : "\uf010" + property string power_off : "\uf011" + property string signal : "\uf012" + property string gear : "\uf013" + property string cog : "\uf013" + property string trash_o : "\uf014" + property string home : "\uf015" + property string file_o : "\uf016" + property string clock_o : "\uf017" + property string road : "\uf018" + property string download : "\uf019" + property string arrow_circle_o_down : "\uf01a" + property string arrow_circle_o_up : "\uf01b" + property string inbox : "\uf01c" + property string play_circle_o : "\uf01d" + property string rotate_right : "\uf01e" + property string repeat : "\uf01e" + property string refresh : "\uf021" + property string list_alt : "\uf022" + property string lock : "\uf023" + property string flag : "\uf024" + property string headphones : "\uf025" + property string volume_off : "\uf026" + property string volume_down : "\uf027" + property string volume_up : "\uf028" + property string qrcode : "\uf029" + property string barcode : "\uf02a" + property string tag : "\uf02b" + property string tags : "\uf02c" + property string book : "\uf02d" + property string bookmark : "\uf02e" + property string printer : "\uf02f" + property string camera : "\uf030" + property string font : "\uf031" + property string bold : "\uf032" + property string italic : "\uf033" + property string text_height : "\uf034" + property string text_width : "\uf035" + property string align_left : "\uf036" + property string align_center : "\uf037" + property string align_right : "\uf038" + property string align_justify : "\uf039" + property string list : "\uf03a" + property string dedent : "\uf03b" + property string outdent : "\uf03b" + property string indent : "\uf03c" + property string video_camera : "\uf03d" + property string photo : "\uf03e" + property string image : "\uf03e" + property string picture_o : "\uf03e" + property string pencil : "\uf040" + property string map_marker : "\uf041" + property string adjust : "\uf042" + property string tint : "\uf043" + property string edit : "\uf044" + property string pencil_square_o : "\uf044" + property string share_square_o : "\uf045" + property string check_square_o : "\uf046" + property string arrows : "\uf047" + property string step_backward : "\uf048" + property string fast_backward : "\uf049" + property string backward : "\uf04a" + property string play : "\uf04b" + property string pause : "\uf04c" + property string stop : "\uf04d" + property string forward : "\uf04e" + property string fast_forward : "\uf050" + property string step_forward : "\uf051" + property string eject : "\uf052" + property string chevron_left : "\uf053" + property string chevron_right : "\uf054" + property string plus_circle : "\uf055" + property string minus_circle : "\uf056" + property string times_circle : "\uf057" + property string check_circle : "\uf058" + property string question_circle : "\uf059" + property string info_circle : "\uf05a" + property string crosshairs : "\uf05b" + property string times_circle_o : "\uf05c" + property string check_circle_o : "\uf05d" + property string ban : "\uf05e" + property string arrow_left : "\uf060" + property string arrow_right : "\uf061" + property string arrow_up : "\uf062" + property string arrow_down : "\uf063" + property string mail_forward : "\uf064" + property string share : "\uf064" + property string expand : "\uf065" + property string compress : "\uf066" + property string plus : "\uf067" + property string minus : "\uf068" + property string asterisk : "\uf069" + property string exclamation_circle : "\uf06a" + property string gift : "\uf06b" + property string leaf : "\uf06c" + property string fire : "\uf06d" + property string eye : "\uf06e" + property string eye_slash : "\uf070" + property string warning : "\uf071" + property string exclamation_triangle : "\uf071" + property string plane : "\uf072" + property string calendar : "\uf073" + property string random : "\uf074" + property string comment : "\uf075" + property string magnet : "\uf076" + property string chevron_up : "\uf077" + property string chevron_down : "\uf078" + property string retweet : "\uf079" + property string shopping_cart : "\uf07a" + property string folder : "\uf07b" + property string folder_open : "\uf07c" + property string arrows_v : "\uf07d" + property string arrows_h : "\uf07e" + property string bar_chart_o : "\uf080" + property string bar_chart : "\uf080" + property string twitter_square : "\uf081" + property string facebook_square : "\uf082" + property string camera_retro : "\uf083" + property string key : "\uf084" + property string gears : "\uf085" + property string cogs : "\uf085" + property string comments : "\uf086" + property string thumbs_o_up : "\uf087" + property string thumbs_o_down : "\uf088" + property string star_half : "\uf089" + property string heart_o : "\uf08a" + property string sign_out : "\uf08b" + property string linkedin_square : "\uf08c" + property string thumb_tack : "\uf08d" + property string external_link : "\uf08e" + property string sign_in : "\uf090" + property string trophy : "\uf091" + property string github_square : "\uf092" + property string upload : "\uf093" + property string lemon_o : "\uf094" + property string phone : "\uf095" + property string square_o : "\uf096" + property string bookmark_o : "\uf097" + property string phone_square : "\uf098" + property string twitter : "\uf099" + property string facebook_f : "\uf09a" + property string facebook : "\uf09a" + property string github : "\uf09b" + property string unlock : "\uf09c" + property string credit_card : "\uf09d" + property string feed : "\uf09e" + property string rss : "\uf09e" + property string hdd_o : "\uf0a0" + property string bullhorn : "\uf0a1" + property string bell : "\uf0f3" + property string certificate : "\uf0a3" + property string hand_o_right : "\uf0a4" + property string hand_o_left : "\uf0a5" + property string hand_o_up : "\uf0a6" + property string hand_o_down : "\uf0a7" + property string arrow_circle_left : "\uf0a8" + property string arrow_circle_right : "\uf0a9" + property string arrow_circle_up : "\uf0aa" + property string arrow_circle_down : "\uf0ab" + property string globe : "\uf0ac" + property string wrench : "\uf0ad" + property string tasks : "\uf0ae" + property string filter : "\uf0b0" + property string briefcase : "\uf0b1" + property string arrows_alt : "\uf0b2" + property string group : "\uf0c0" + property string users : "\uf0c0" + property string chain : "\uf0c1" + property string link : "\uf0c1" + property string cloud : "\uf0c2" + property string flask : "\uf0c3" + property string cut : "\uf0c4" + property string scissors : "\uf0c4" + property string copy : "\uf0c5" + property string files_o : "\uf0c5" + property string paperclip : "\uf0c6" + property string save : "\uf0c7" + property string floppy_o : "\uf0c7" + property string square : "\uf0c8" + property string navicon : "\uf0c9" + property string reorder : "\uf0c9" + property string bars : "\uf0c9" + property string list_ul : "\uf0ca" + property string list_ol : "\uf0cb" + property string strikethrough : "\uf0cc" + property string underline : "\uf0cd" + property string table : "\uf0ce" + property string magic : "\uf0d0" + property string truck : "\uf0d1" + property string pinterest : "\uf0d2" + property string pinterest_square : "\uf0d3" + property string google_plus_square : "\uf0d4" + property string google_plus : "\uf0d5" + property string money : "\uf0d6" + property string caret_down : "\uf0d7" + property string caret_up : "\uf0d8" + property string caret_left : "\uf0d9" + property string caret_right : "\uf0da" + property string columns : "\uf0db" + property string unsorted : "\uf0dc" + property string sort : "\uf0dc" + property string sort_down : "\uf0dd" + property string sort_desc : "\uf0dd" + property string sort_up : "\uf0de" + property string sort_asc : "\uf0de" + property string envelope : "\uf0e0" + property string linkedin : "\uf0e1" + property string rotate_left : "\uf0e2" + property string undo : "\uf0e2" + property string legal : "\uf0e3" + property string gavel : "\uf0e3" + property string dashboard : "\uf0e4" + property string tachometer : "\uf0e4" + property string comment_o : "\uf0e5" + property string comments_o : "\uf0e6" + property string flash : "\uf0e7" + property string bolt : "\uf0e7" + property string sitemap : "\uf0e8" + property string umbrella : "\uf0e9" + property string paste : "\uf0ea" + property string clipboard : "\uf0ea" + property string lightbulb_o : "\uf0eb" + property string exchange : "\uf0ec" + property string cloud_download : "\uf0ed" + property string cloud_upload : "\uf0ee" + property string user_md : "\uf0f0" + property string stethoscope : "\uf0f1" + property string suitcase : "\uf0f2" + property string bell_o : "\uf0a2" + property string coffee : "\uf0f4" + property string cutlery : "\uf0f5" + property string file_text_o : "\uf0f6" + property string building_o : "\uf0f7" + property string hospital_o : "\uf0f8" + property string ambulance : "\uf0f9" + property string medkit : "\uf0fa" + property string fighter_jet : "\uf0fb" + property string beer : "\uf0fc" + property string h_square : "\uf0fd" + property string plus_square : "\uf0fe" + property string angle_double_left : "\uf100" + property string angle_double_right : "\uf101" + property string angle_double_up : "\uf102" + property string angle_double_down : "\uf103" + property string angle_left : "\uf104" + property string angle_right : "\uf105" + property string angle_up : "\uf106" + property string angle_down : "\uf107" + property string desktop : "\uf108" + property string laptop : "\uf109" + property string tablet : "\uf10a" + property string mobile_phone : "\uf10b" + property string mobile : "\uf10b" + property string circle_o : "\uf10c" + property string quote_left : "\uf10d" + property string quote_right : "\uf10e" + property string spinner : "\uf110" + property string circle : "\uf111" + property string mail_reply : "\uf112" + property string reply : "\uf112" + property string github_alt : "\uf113" + property string folder_o : "\uf114" + property string folder_open_o : "\uf115" + property string smile_o : "\uf118" + property string frown_o : "\uf119" + property string meh_o : "\uf11a" + property string gamepad : "\uf11b" + property string keyboard_o : "\uf11c" + property string flag_o : "\uf11d" + property string flag_checkered : "\uf11e" + property string terminal : "\uf120" + property string code : "\uf121" + property string mail_reply_all : "\uf122" + property string reply_all : "\uf122" + property string star_half_empty : "\uf123" + property string star_half_full : "\uf123" + property string star_half_o : "\uf123" + property string location_arrow : "\uf124" + property string crop : "\uf125" + property string code_fork : "\uf126" + property string unlink : "\uf127" + property string chain_broken : "\uf127" + property string question : "\uf128" + property string info : "\uf129" + property string exclamation : "\uf12a" + property string superscript : "\uf12b" + property string subscript : "\uf12c" + property string eraser : "\uf12d" + property string puzzle_piece : "\uf12e" + property string microphone : "\uf130" + property string microphone_slash : "\uf131" + property string shield : "\uf132" + property string calendar_o : "\uf133" + property string fire_extinguisher : "\uf134" + property string rocket : "\uf135" + property string maxcdn : "\uf136" + property string chevron_circle_left : "\uf137" + property string chevron_circle_right : "\uf138" + property string chevron_circle_up : "\uf139" + property string chevron_circle_down : "\uf13a" + property string html5 : "\uf13b" + property string css3 : "\uf13c" + property string anchor : "\uf13d" + property string unlock_alt : "\uf13e" + property string bullseye : "\uf140" + property string ellipsis_h : "\uf141" + property string ellipsis_v : "\uf142" + property string rss_square : "\uf143" + property string play_circle : "\uf144" + property string ticket : "\uf145" + property string minus_square : "\uf146" + property string minus_square_o : "\uf147" + property string level_up : "\uf148" + property string level_down : "\uf149" + property string check_square : "\uf14a" + property string pencil_square : "\uf14b" + property string external_link_square : "\uf14c" + property string share_square : "\uf14d" + property string compass : "\uf14e" + property string toggle_down : "\uf150" + property string caret_square_o_down : "\uf150" + property string toggle_up : "\uf151" + property string caret_square_o_up : "\uf151" + property string toggle_right : "\uf152" + property string caret_square_o_right : "\uf152" + property string euro : "\uf153" + property string eur : "\uf153" + property string gbp : "\uf154" + property string dollar : "\uf155" + property string usd : "\uf155" + property string rupee : "\uf157" + property string inr : "\uf156" + property string cny : "\uf157" + property string rmb : "\uf157" + property string yen : "\uf157" + property string jpy : "\uf157" + property string ruble : "\uf158" + property string rouble : "\uf158" + property string rub : "\uf158" + property string won : "\uf159" + property string krw : "\uf159" + property string bitcoin : "\uf15a" + property string btc : "\uf15a" + property string file : "\uf15b" + property string file_text : "\uf15c" + property string sort_alpha_asc : "\uf15d" + property string sort_alpha_desc : "\uf15e" + property string sort_amount_asc : "\uf160" + property string sort_amount_desc : "\uf161" + property string sort_numeric_asc : "\uf162" + property string sort_numeric_desc : "\uf163" + property string thumbs_up : "\uf164" + property string thumbs_down : "\uf165" + property string youtube_square : "\uf166" + property string youtube : "\uf167" + property string xing : "\uf168" + property string xing_square : "\uf169" + property string youtube_play : "\uf16a" + property string dropbox : "\uf16b" + property string stack_overflow : "\uf16c" + property string instagram : "\uf16d" + property string flickr : "\uf16e" + property string adn : "\uf170" + property string bitbucket : "\uf171" + property string bitbucket_square : "\uf172" + property string tumblr : "\uf173" + property string tumblr_square : "\uf174" + property string long_arrow_down : "\uf175" + property string long_arrow_up : "\uf176" + property string long_arrow_left : "\uf177" + property string long_arrow_right : "\uf178" + property string apple : "\uf179" + property string windows : "\uf17a" + property string android : "\uf17b" + property string linux : "\uf17c" + property string dribbble : "\uf17d" + property string skype : "\uf17e" + property string foursquare : "\uf180" + property string trello : "\uf181" + property string female : "\uf182" + property string male : "\uf183" + property string gittip : "\uf184" + property string gratipay : "\uf184" + property string sun_o : "\uf185" + property string moon_o : "\uf186" + property string archive : "\uf187" + property string bug : "\uf188" + property string vk : "\uf189" + property string weibo : "\uf18a" + property string renren : "\uf18b" + property string pagelines : "\uf18c" + property string stack_exchange : "\uf18d" + property string arrow_circle_o_right : "\uf18e" + property string arrow_circle_o_left : "\uf190" + property string toggle_left : "\uf191" + property string caret_square_o_left : "\uf191" + property string dot_circle_o : "\uf192" + property string wheelchair : "\uf193" + property string vimeo_square : "\uf194" + property string turkish_lira : "\uf195" + property string fa_try : "\uf195" + property string plus_square_o : "\uf196" + property string space_shuttle : "\uf197" + property string slack : "\uf198" + property string envelope_square : "\uf199" + property string wordpress : "\uf19a" + property string openid : "\uf19b" + property string institution : "\uf19c" + property string bank : "\uf19c" + property string university : "\uf19c" + property string mortar_board : "\uf19d" + property string graduation_cap : "\uf19d" + property string yahoo : "\uf19e" + property string google : "\uf1a0" + property string reddit : "\uf1a1" + property string reddit_square : "\uf1a2" + property string stumbleupon_circle : "\uf1a3" + property string stumbleupon : "\uf1a4" + property string delicious : "\uf1a5" + property string digg : "\uf1a6" + property string pied_piper_pp : "\uf1a7" + property string pied_piper_alt : "\uf1a8" + property string drupal : "\uf1a9" + property string joomla : "\uf1aa" + property string language : "\uf1ab" + property string fax : "\uf1ac" + property string building : "\uf1ad" + property string child : "\uf1ae" + property string paw : "\uf1b0" + property string spoon : "\uf1b1" + property string cube : "\uf1b2" + property string cubes : "\uf1b3" + property string behance : "\uf1b4" + property string behance_square : "\uf1b5" + property string steam : "\uf1b6" + property string steam_square : "\uf1b7" + property string recycle : "\uf1b8" + property string automobile : "\uf1b9" + property string car : "\uf1b9" + property string cab : "\uf1ba" + property string taxi : "\uf1ba" + property string tree : "\uf1bb" + property string spotify : "\uf1bc" + property string deviantart : "\uf1bd" + property string soundcloud : "\uf1be" + property string database : "\uf1c0" + property string file_pdf_o : "\uf1c1" + property string file_word_o : "\uf1c2" + property string file_excel_o : "\uf1c3" + property string file_powerpoint_o : "\uf1c4" + property string file_photo_o : "\uf1c5" + property string file_picture_o : "\uf1c5" + property string file_image_o : "\uf1c5" + property string file_zip_o : "\uf1c6" + property string file_archive_o : "\uf1c6" + property string file_sound_o : "\uf1c7" + property string file_audio_o : "\uf1c7" + property string file_movie_o : "\uf1c8" + property string file_video_o : "\uf1c8" + property string file_code_o : "\uf1c9" + property string vine : "\uf1ca" + property string codepen : "\uf1cb" + property string jsfiddle : "\uf1cc" + property string life_bouy : "\uf1cd" + property string life_buoy : "\uf1cd" + property string life_saver : "\uf1cd" + property string support : "\uf1cd" + property string life_ring : "\uf1cd" + property string circle_o_notch : "\uf1ce" + property string ra : "\uf1d0" + property string resistance : "\uf1d0" + property string rebel : "\uf1d0" + property string ge : "\uf1d1" + property string empire : "\uf1d1" + property string git_square : "\uf1d2" + property string git : "\uf1d3" + property string y_combinator_square : "\uf1d4" + property string yc_square : "\uf1d4" + property string hacker_news : "\uf1d4" + property string tencent_weibo : "\uf1d5" + property string qq : "\uf1d6" + property string wechat : "\uf1d7" + property string weixin : "\uf1d7" + property string send : "\uf1d8" + property string paper_plane : "\uf1d8" + property string send_o : "\uf1d9" + property string paper_plane_o : "\uf1d9" + property string history : "\uf1da" + property string circle_thin : "\uf1db" + property string header : "\uf1dc" + property string paragraph : "\uf1dd" + property string sliders : "\uf1de" + property string share_alt : "\uf1e0" + property string share_alt_square : "\uf1e1" + property string bomb : "\uf1e2" + property string soccer_ball_o : "\uf1e3" + property string futbol_o : "\uf1e3" + property string tty : "\uf1e4" + property string binoculars : "\uf1e5" + property string plug : "\uf1e6" + property string slideshare : "\uf1e7" + property string twitch : "\uf1e8" + property string yelp : "\uf1e9" + property string newspaper_o : "\uf1ea" + property string wifi : "\uf1eb" + property string calculator : "\uf1ec" + property string paypal : "\uf1ed" + property string google_wallet : "\uf1ee" + property string cc_visa : "\uf1f0" + property string cc_mastercard : "\uf1f1" + property string cc_discover : "\uf1f2" + property string cc_amex : "\uf1f3" + property string cc_paypal : "\uf1f4" + property string cc_stripe : "\uf1f5" + property string bell_slash : "\uf1f6" + property string bell_slash_o : "\uf1f7" + property string trash : "\uf1f8" + property string copyright : "\uf1f9" + property string at : "\uf1fa" + property string eyedropper : "\uf1fb" + property string paint_brush : "\uf1fc" + property string birthday_cake : "\uf1fd" + property string area_chart : "\uf1fe" + property string pie_chart : "\uf200" + property string line_chart : "\uf201" + property string lastfm : "\uf202" + property string lastfm_square : "\uf203" + property string toggle_off : "\uf204" + property string toggle_on : "\uf205" + property string bicycle : "\uf206" + property string bus : "\uf207" + property string ioxhost : "\uf208" + property string angellist : "\uf209" + property string cc : "\uf20a" + property string shekel : "\uf20b" + property string sheqel : "\uf20b" + property string ils : "\uf20b" + property string meanpath : "\uf20c" + property string buysellads : "\uf20d" + property string connectdevelop : "\uf20e" + property string dashcube : "\uf210" + property string forumbee : "\uf211" + property string leanpub : "\uf212" + property string sellsy : "\uf213" + property string shirtsinbulk : "\uf214" + property string simplybuilt : "\uf215" + property string skyatlas : "\uf216" + property string cart_plus : "\uf217" + property string cart_arrow_down : "\uf218" + property string diamond : "\uf219" + property string ship : "\uf21a" + property string user_secret : "\uf21b" + property string motorcycle : "\uf21c" + property string street_view : "\uf21d" + property string heartbeat : "\uf21e" + property string venus : "\uf221" + property string mars : "\uf222" + property string mercury : "\uf223" + property string intersex : "\uf224" + property string transgender : "\uf224" + property string transgender_alt : "\uf225" + property string venus_double : "\uf226" + property string mars_double : "\uf227" + property string venus_mars : "\uf228" + property string mars_stroke : "\uf229" + property string mars_stroke_v : "\uf22a" + property string mars_stroke_h : "\uf22b" + property string neuter : "\uf22c" + property string genderless : "\uf22d" + property string facebook_official : "\uf230" + property string pinterest_p : "\uf231" + property string whatsapp : "\uf232" + property string server : "\uf233" + property string user_plus : "\uf234" + property string user_times : "\uf235" + property string hotel : "\uf236" + property string bed : "\uf236" + property string viacoin : "\uf237" + property string train : "\uf238" + property string subway : "\uf239" + property string medium : "\uf23a" + property string yc : "\uf23b" + property string y_combinator : "\uf23b" + property string optin_monster : "\uf23c" + property string opencart : "\uf23d" + property string expeditedssl : "\uf23e" + property string battery_4 : "\uf240" + property string battery : "\uf240" + property string battery_full : "\uf240" + property string battery_3 : "\uf241" + property string battery_three_quarters : "\uf241" + property string battery_2 : "\uf242" + property string battery_half : "\uf242" + property string battery_1 : "\uf243" + property string battery_quarter : "\uf243" + property string battery_0 : "\uf244" + property string battery_empty : "\uf244" + property string mouse_pointer : "\uf245" + property string i_cursor : "\uf246" + property string object_group : "\uf247" + property string object_ungroup : "\uf248" + property string sticky_note : "\uf249" + property string sticky_note_o : "\uf24a" + property string cc_jcb : "\uf24b" + property string cc_diners_club : "\uf24c" + property string clone : "\uf24d" + property string balance_scale : "\uf24e" + property string hourglass_o : "\uf250" + property string hourglass_1 : "\uf251" + property string hourglass_start : "\uf251" + property string hourglass_2 : "\uf252" + property string hourglass_half : "\uf252" + property string hourglass_3 : "\uf253" + property string hourglass_end : "\uf253" + property string hourglass : "\uf254" + property string hand_grab_o : "\uf255" + property string hand_rock_o : "\uf255" + property string hand_stop_o : "\uf256" + property string hand_paper_o : "\uf256" + property string hand_scissors_o : "\uf257" + property string hand_lizard_o : "\uf258" + property string hand_spock_o : "\uf259" + property string hand_pointer_o : "\uf25a" + property string hand_peace_o : "\uf25b" + property string trademark : "\uf25c" + property string registered : "\uf25d" + property string creative_commons : "\uf25e" + property string gg : "\uf260" + property string gg_circle : "\uf261" + property string tripadvisor : "\uf262" + property string odnoklassniki : "\uf263" + property string odnoklassniki_square : "\uf264" + property string get_pocket : "\uf265" + property string wikipedia_w : "\uf266" + property string safari : "\uf267" + property string chrome : "\uf268" + property string firefox : "\uf269" + property string opera : "\uf26a" + property string internet_explorer : "\uf26b" + property string tv : "\uf26c" + property string television : "\uf26c" + property string contao : "\uf26d" + property string fa_500px : "\uf26e" + property string amazon : "\uf270" + property string calendar_plus_o : "\uf271" + property string calendar_minus_o : "\uf272" + property string calendar_times_o : "\uf273" + property string calendar_check_o : "\uf274" + property string industry : "\uf275" + property string map_pin : "\uf276" + property string map_signs : "\uf277" + property string map_o : "\uf278" + property string map : "\uf279" + property string commenting : "\uf27a" + property string commenting_o : "\uf27b" + property string houzz : "\uf27c" + property string vimeo : "\uf27d" + property string black_tie : "\uf27e" + property string fonticons : "\uf280" + property string reddit_alien : "\uf281" + property string edge : "\uf282" + property string credit_card_alt : "\uf283" + property string codiepie : "\uf284" + property string modx : "\uf285" + property string fort_awesome : "\uf286" + property string usb : "\uf287" + property string product_hunt : "\uf288" + property string mixcloud : "\uf289" + property string scribd : "\uf28a" + property string pause_circle : "\uf28b" + property string pause_circle_o : "\uf28c" + property string stop_circle : "\uf28d" + property string stop_circle_o : "\uf28e" + property string shopping_bag : "\uf290" + property string shopping_basket : "\uf291" + property string hashtag : "\uf292" + property string bluetooth : "\uf293" + property string bluetooth_b : "\uf294" + property string percent : "\uf295" + property string gitlab : "\uf296" + property string wpbeginner : "\uf297" + property string wpforms : "\uf298" + property string envira : "\uf299" + property string universal_access : "\uf29a" + property string wheelchair_alt : "\uf29b" + property string question_circle_o : "\uf29c" + property string blind : "\uf29d" + property string audio_description : "\uf29e" + property string volume_control_phone : "\uf2a0" + property string braille : "\uf2a1" + property string assistive_listening_systems : "\uf2a2" + property string asl_interpreting : "\uf2a3" + property string american_sign_language_interpreting : "\uf2a3" + property string deafness : "\uf2a4" + property string hard_of_hearing : "\uf2a4" + property string deaf : "\uf2a4" + property string glide : "\uf2a5" + property string glide_g : "\uf2a6" + property string signing : "\uf2a7" + property string sign_language : "\uf2a7" + property string low_vision : "\uf2a8" + property string viadeo : "\uf2a9" + property string viadeo_square : "\uf2aa" + property string snapchat : "\uf2ab" + property string snapchat_ghost : "\uf2ac" + property string snapchat_square : "\uf2ad" + property string pied_piper : "\uf2ae" + property string first_order : "\uf2b0" + property string yoast : "\uf2b1" + property string themeisle : "\uf2b2" + property string google_plus_circle : "\uf2b3" + property string google_plus_official : "\uf2b3" + property string fa : "\uf2b4" + property string font_awesome : "\uf2b4" + property string handshake_o : "\uf2b5" + property string envelope_open : "\uf2b6" + property string envelope_open_o : "\uf2b7" + property string linode : "\uf2b8" + property string address_book : "\uf2b9" + property string address_book_o : "\uf2ba" + property string vcard : "\uf2bb" + property string address_card : "\uf2bb" + property string vcard_o : "\uf2bc" + property string address_card_o : "\uf2bc" + property string user_circle : "\uf2bd" + property string user_circle_o : "\uf2be" + property string user_o : "\uf2c0" + property string id_badge : "\uf2c1" + property string drivers_license : "\uf2c2" + property string id_card : "\uf2c2" + property string drivers_license_o : "\uf2c3" + property string id_card_o : "\uf2c3" + property string quora : "\uf2c4" + property string free_code_camp : "\uf2c5" + property string telegram : "\uf2c6" + property string thermometer_4 : "\uf2c7" + property string thermometer : "\uf2c7" + property string thermometer_full : "\uf2c7" + property string thermometer_3 : "\uf2c8" + property string thermometer_three_quarters : "\uf2c8" + property string thermometer_2 : "\uf2c9" + property string thermometer_half : "\uf2c9" + property string thermometer_1 : "\uf2ca" + property string thermometer_quarter : "\uf2ca" + property string thermometer_0 : "\uf2cb" + property string thermometer_empty : "\uf2cb" + property string shower : "\uf2cc" + property string bathtub : "\uf2cd" + property string s15 : "\uf2cd" + property string bath : "\uf2cd" + property string podcast : "\uf2ce" + property string window_maximize : "\uf2d0" + property string window_minimize : "\uf2d1" + property string window_restore : "\uf2d2" + property string times_rectangle : "\uf2d3" + property string window_close : "\uf2d3" + property string times_rectangle_o : "\uf2d4" + property string window_close_o : "\uf2d4" + property string bandcamp : "\uf2d5" + property string grav : "\uf2d6" + property string etsy : "\uf2d7" + property string imdb : "\uf2d8" + property string ravelry : "\uf2d9" + property string eercast : "\uf2da" + property string microchip : "\uf2db" + property string snowflake_o : "\uf2dc" + property string superpowers : "\uf2dd" + property string wpexplorer : "\uf2de" + property string meetup : "\uf2e0" + } +} diff --git a/internal/frontend/qml/ProtonUI/TLSCertPinIssueBar.qml b/internal/frontend/qml/ProtonUI/TLSCertPinIssueBar.qml new file mode 100644 index 00000000..76171b82 --- /dev/null +++ b/internal/frontend/qml/ProtonUI/TLSCertPinIssueBar.qml @@ -0,0 +1,69 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Important information under title bar + +import QtQuick 2.8 +import QtQuick.Window 2.2 +import QtQuick.Controls 2.1 +import ProtonUI 1.0 + +Rectangle { + id: root + height: 0 + visible: state != "ok" + state: "ok" + color: "black" + property var fontSize : 1.0 * Style.main.fontSize + + Row { + anchors.centerIn: root + visible: root.visible + spacing: Style.main.leftMarginButton + + AccessibleText { + id: message + font.pointSize: root.fontSize * Style.pt + + text: qsTr("Connection security error: Your network connection to Proton services may be insecure.", "message in bar showed when TLS Pinning fails") + } + + ClickIconText { + anchors.verticalCenter : message.verticalCenter + iconText : "" + text : qsTr("Learn more", "This button opens TLS Pinning issue modal with more explanation") + visible : root.visible + onClicked : { + winMain.dialogTlsCert.show() + } + fontSize : root.fontSize + textUnderline: true + } + } + + + states: [ + State { + name: "notOK" + PropertyChanges { + target: root + height: 2* Style.main.fontSize + color: Style.main.textRed + } + } + ] +} diff --git a/internal/frontend/qml/ProtonUI/TabButton.qml b/internal/frontend/qml/ProtonUI/TabButton.qml new file mode 100644 index 00000000..6708dfbe --- /dev/null +++ b/internal/frontend/qml/ProtonUI/TabButton.qml @@ -0,0 +1,103 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Button with text and icon for tabbar + +import QtQuick 2.8 +import ProtonUI 1.0 +import QtQuick.Controls 2.1 + +AccessibleButton { + id: root + property alias iconText : icon.text + property alias title : titleText.text + property color textColor : { + if (root.state=="deactivated") { + return Qt.lighter(Style.tabbar.textInactive, root.hovered || root.activeFocus ? 1.25 : 1.0) + } + if (root.state=="activated") { + return Style.tabbar.text + } + } + + text: root.title + Accessible.description: root.title + " tab" + + width : titleMetrics.width // Style.tabbar.widthButton + height : Style.tabbar.heightButton + padding: 0 + + background: Rectangle { + color : Style.transparent + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.NoButton + } + } + + contentItem : Rectangle { + color: "transparent" + scale : root.pressed ? 0.96 : 1.00 + + Text { + id: icon + // dimenstions + anchors { + top : parent.top + horizontalCenter : parent.horizontalCenter + } + // style + color : root.textColor + font { + family : Style.fontawesome.name + pointSize : Style.tabbar.iconSize * Style.pt + } + } + + TextMetrics { + id: titleMetrics + text : root.title + font.pointSize : titleText.font.pointSize + } + + Text { + id: titleText + // dimenstions + anchors { + bottom : parent.bottom + horizontalCenter : parent.horizontalCenter + } + // style + color : root.textColor + font { + pointSize : Style.tabbar.fontSize * Style.pt + bold : root.state=="activated" + } + } + } + + states: [ + State { + name: "activated" + }, + State { + name: "deactivated" + } + ] +} diff --git a/internal/frontend/qml/ProtonUI/TabLabels.qml b/internal/frontend/qml/ProtonUI/TabLabels.qml new file mode 100644 index 00000000..cc7b1bf7 --- /dev/null +++ b/internal/frontend/qml/ProtonUI/TabLabels.qml @@ -0,0 +1,107 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Tab labels + +import QtQuick 2.8 +import ProtonUI 1.0 + + +Rectangle { + id: root + // attributes + property alias model : tablist.model + property alias currentIndex : tablist.currentIndex + property int spacing : Style.tabbar.widthButton + Style.tabbar.spacingButton + currentIndex: 0 + + // appereance + height : Style.tabbar.height + color : Style.tabbar.background + + // content + ListView { + id: tablist + // dimensions + anchors { + fill: root + leftMargin : Style.tabbar.leftMargin + rightMargin : Style.main.rightMargin + bottomMargin : Style.tabbar.bottomMargin + } + spacing: Style.tabbar.spacingButton + interactive : false + // style + orientation: Qt.Horizontal + delegate: TabButton { + anchors.bottom : parent.bottom + title : modelData.title + iconText : modelData.iconText + state : index == tablist.currentIndex ? "activated" : "deactivated" + onClicked : { + tablist.currentIndex = index + } + } + } + + // Quit button + TabButton { + id: buttonQuit + title : qsTr("Close Bridge", "quits the application") + iconText : Style.fa.power_off + state : "deactivated" + visible : Style.tabbar.rightButton=="quit" + anchors { + right : root.right + bottom : root.bottom + rightMargin : Style.main.rightMargin + bottomMargin : Style.tabbar.bottomMargin + } + + Accessible.description: buttonQuit.title + + onClicked : { + dialogGlobal.state = "quit" + dialogGlobal.show() + } + } + + // Add account + TabButton { + id: buttonAddAccount + title : qsTr("Add account", "start the authentication to add account") + iconText : Style.fa.plus_circle + state : "deactivated" + visible : Style.tabbar.rightButton=="add account" + anchors { + right : root.right + bottom : root.bottom + rightMargin : Style.main.rightMargin + bottomMargin : Style.tabbar.bottomMargin + } + + Accessible.description: buttonAddAccount.title + + onClicked : dialogAddUser.show() + } + + function focusButton() { + tablist.currentItem.forceActiveFocus() + tablist.currentItem.Accessible.focusedChanged(true) + } +} + diff --git a/internal/frontend/qml/ProtonUI/TextLabel.qml b/internal/frontend/qml/ProtonUI/TextLabel.qml new file mode 100644 index 00000000..b631252e --- /dev/null +++ b/internal/frontend/qml/ProtonUI/TextLabel.qml @@ -0,0 +1,45 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +import QtQuick 2.8 +import ProtonUI 1.0 + +AccessibleText{ + id: root + property bool hasCopyButton : false + font.pointSize: Style.main.fontSize * Style.pt + state: "label" + + states : [ + State { + name: "label" + PropertyChanges { + target : root + font.bold : false + color : Style.main.textDisabled + } + }, + State { + name: "heading" + PropertyChanges { + target : root + font.bold : true + color : Style.main.textDisabled + } + } + ] +} diff --git a/internal/frontend/qml/ProtonUI/TextValue.qml b/internal/frontend/qml/ProtonUI/TextValue.qml new file mode 100644 index 00000000..a3e770c8 --- /dev/null +++ b/internal/frontend/qml/ProtonUI/TextValue.qml @@ -0,0 +1,85 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +import QtQuick 2.8 +import ProtonUI 1.0 + +Rectangle { + id: root + property string text: "undef" + width: copyIcon.width + valueText.width + height: Math.max(copyIcon.height, valueText.contentHeight) + color: "transparent" + + Rectangle { + id: copyIcon + width: Style.info.leftMarginIcon*2 + Style.info.iconSize + height : Style.info.iconSize + color: "transparent" + anchors { + top: root.top + left: root.left + } + Text { + anchors.centerIn: parent + font { + pointSize : Style.info.iconSize * Style.pt + family : Style.fontawesome.name + } + color : Style.main.textInactive + text: Style.fa.copy + } + MouseArea { + anchors.fill: parent + onClicked : { + valueText.select(0, valueText.length) + valueText.copy() + valueText.deselect() + } + onPressed: copyIcon.scale = 0.90 + onReleased: copyIcon.scale = 1 + } + + Accessible.role: Accessible.Button + Accessible.name: qsTr("Copy %1 to clipboard", "Click to copy the value to system clipboard.").arg(root.text) + Accessible.description: Accessible.name + } + + TextEdit { + id: valueText + width: Style.info.widthValue + height: Style.main.fontSize + anchors { + top: root.top + left: copyIcon.right + } + font { + pointSize: Style.main.fontSize * Style.pt + } + color: Style.main.text + readOnly: true + selectByMouse: true + selectByKeyboard: true + wrapMode: TextEdit.Wrap + text: root.text + selectionColor: Style.dialog.textBlue + + Accessible.role: Accessible.StaticText + Accessible.name: root.text + Accessible.description: Accessible.name + } +} diff --git a/internal/frontend/qml/ProtonUI/WindowTitleBar.qml b/internal/frontend/qml/ProtonUI/WindowTitleBar.qml new file mode 100644 index 00000000..c48efafc --- /dev/null +++ b/internal/frontend/qml/ProtonUI/WindowTitleBar.qml @@ -0,0 +1,348 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// simulating window title bar with different color + +import QtQuick 2.8 +import QtQuick.Window 2.2 +import ProtonUI 1.0 + +Rectangle { + id: root + height: root.isDarwin ? Style.titleMacOS.height : Style.title.height + color: "transparent" + property bool isDarwin : (go.goos == "darwin") + property QtObject window + anchors { + left : parent.left + right : parent.right + top : parent.top + } + + MouseArea { + property point diff: "0,0" + anchors { + top: root.top + bottom: root.bottom + left: root.left + right: root.isDarwin ? root.right : iconRowWin.left + } + onPressed: { + diff = Qt.point(window.x, window.y) + var mousePos = mapToGlobal(mouse.x, mouse.y) + diff.x -= mousePos.x + diff.y -= mousePos.y + } + onPositionChanged: { + var currPos = mapToGlobal(mouse.x, mouse.y) + window.x = currPos.x + diff.x + window.y = currPos.y + diff.y + } + } + + // top background + Rectangle { + id: upperBackground + anchors.fill: root + color: (isDarwin? Style.titleMacOS.background : Style.title.background ) + radius: (isDarwin? Style.titleMacOS.radius : 0) + border { + width: Style.main.border + color: Style.title.background + } + } + // bottom background + Rectangle { + id: lowerBorder + anchors { + top: root.verticalCenter + left: root.left + right: root.right + bottom: root.bottom + } + color: Style.title.background + Rectangle { + id: lowerBackground + anchors{ + fill : parent + leftMargin : Style.main.border + rightMargin : Style.main.border + } + color: upperBackground.color + + } + } + + // Title + TextMetrics { + id: titleMetrics + text : window.title + font : isDarwin ? titleMac.font : titleWin.font + elide: Qt.ElideMiddle + elideWidth : window.width/2 + } + Text { + id: titleWin + visible: !isDarwin + anchors { + baseline : logo.bottom + left : logo.right + leftMargin : Style.title.leftMargin/1.5 + } + color : window.active ? Style.title.text : Style.main.textDisabled + text : titleMetrics.elidedText + font.pointSize : Style.main.fontSize * Style.pt + } + Text { + id: titleMac + visible: isDarwin + anchors { + verticalCenter : parent.verticalCenter + left : parent.left + leftMargin : (parent.width-width)/2 + } + color : window.active ? Style.title.text : Style.main.textDisabled + text : titleMetrics.elidedText + font.pointSize : Style.main.fontSize * Style.pt + } + + + // MACOS + MouseArea { + anchors.fill: iconRowMac + property string beforeHover + hoverEnabled: true + onEntered: { + beforeHover=iconRed.state + //iconYellow.state="hover" + iconRed.state="hover" + } + onExited: { + //iconYellow.state=beforeHover + iconRed.state=beforeHover + } + } + Connections { + target: window + onActiveChanged : { + if (window.active) { + //iconYellow.state="normal" + iconRed.state="normal" + } else { + //iconYellow.state="disabled" + iconRed.state="disabled" + } + } + } + Row { + id: iconRowMac + visible : isDarwin + spacing : Style.titleMacOS.leftMargin + anchors { + left : parent.left + verticalCenter : parent.verticalCenter + leftMargin : Style.title.leftMargin + } + Image { + id: iconRed + width : Style.titleMacOS.imgHeight + height : Style.titleMacOS.imgHeight + fillMode : Image.PreserveAspectFit + smooth : true + state : "normal" + states: [ + State { name: "normal" ; PropertyChanges { target: iconRed ; source: "images/macos_red.png" } }, + State { name: "hover" ; PropertyChanges { target: iconRed ; source: "images/macos_red_hl.png" } }, + State { name: "pressed" ; PropertyChanges { target: iconRed ; source: "images/macos_red_dark.png" } }, + State { name: "disabled" ; PropertyChanges { target: iconRed ; source: "images/macos_gray.png" } } + ] + MouseArea { + anchors.fill: parent + property string beforePressed : "normal" + onClicked : { + window.close() + } + onPressed: { + beforePressed = parent.state + parent.state="pressed" + } + onReleased: { + parent.state=beforePressed + } + Accessible.role: Accessible.Button + Accessible.name: qsTr("Close", "Close the window button") + Accessible.description: Accessible.name + Accessible.ignored: !parent.visible + Accessible.onPressAction: { + window.close() + } + } + } + Image { + id: iconYellow + width : Style.titleMacOS.imgHeight + height : Style.titleMacOS.imgHeight + fillMode : Image.PreserveAspectFit + smooth : true + state : "disabled" + states: [ + State { name: "normal" ; PropertyChanges { target: iconYellow ; source: "images/macos_yellow.png" } }, + State { name: "hover" ; PropertyChanges { target: iconYellow ; source: "images/macos_yellow_hl.png" } }, + State { name: "pressed" ; PropertyChanges { target: iconYellow ; source: "images/macos_yellow_dark.png" } }, + State { name: "disabled" ; PropertyChanges { target: iconYellow ; source: "images/macos_gray.png" } } + ] + /* + MouseArea { + anchors.fill: parent + property string beforePressed : "normal" + onClicked : { + window.visibility = Window.Minimized + } + onPressed: { + beforePressed = parent.state + parent.state="pressed" + } + onReleased: { + parent.state=beforePressed + } + + Accessible.role: Accessible.Button + Accessible.name: qsTr("Minimize", "Minimize the window button") + Accessible.description: Accessible.name + Accessible.ignored: !parent.visible + Accessible.onPressAction: { + window.visibility = Window.Minimized + } + } + */ + } + Image { + id: iconGreen + width : Style.titleMacOS.imgHeight + height : Style.titleMacOS.imgHeight + fillMode : Image.PreserveAspectFit + smooth : true + source : "images/macos_gray.png" + Component.onCompleted : { + visible = false // (window.flags&Qt.Dialog) != Qt.Dialog + } + } + } + + + // Windows + Image { + id: logo + visible: !isDarwin + anchors { + left : parent.left + verticalCenter : parent.verticalCenter + leftMargin : Style.title.leftMargin + } + height : Style.title.fontSize-2*Style.px + fillMode : Image.PreserveAspectFit + mipmap : true + source : "images/pm_logo.png" + } + + Row { + id: iconRowWin + visible: !isDarwin + anchors { + right : parent.right + verticalCenter : root.verticalCenter + } + Rectangle { + height : root.height + width : 1.5*height + color: Style.transparent + Image { + id: iconDash + anchors.centerIn: parent + height : iconTimes.height*0.90 + fillMode : Image.PreserveAspectFit + mipmap : true + source : "images/win10_Dash.png" + } + MouseArea { + anchors.fill : parent + hoverEnabled : true + onClicked : { + window.visibility = Window.Minimized + } + onPressed: { + parent.scale=0.92 + } + onReleased: { + parent.scale=1 + } + onEntered: { + parent.color= Qt.lighter(Style.title.background,1.2) + } + onExited: { + parent.color=Style.transparent + } + + Accessible.role : Accessible.Button + Accessible.name : qsTr("Minimize", "Minimize the window button") + Accessible.description : Accessible.name + Accessible.ignored : !parent.visible + Accessible.onPressAction : { + window.visibility = Window.Minimized + } + } + } + Rectangle { + height : root.height + width : 1.5*height + color : Style.transparent + Image { + id: iconTimes + anchors.centerIn : parent + mipmap : true + height : parent.height/1.5 + fillMode : Image.PreserveAspectFit + source : "images/win10_Times.png" + } + MouseArea { + anchors.fill : parent + hoverEnabled : true + onClicked : window.close() + onPressed : { + iconTimes.scale=0.92 + } + onReleased: { + parent.scale=1 + } + onEntered: { + parent.color=Style.main.textRed + } + onExited: { + parent.color=Style.transparent + } + + Accessible.role : Accessible.Button + Accessible.name : qsTr("Close", "Close the window button") + Accessible.description : Accessible.name + Accessible.ignored : !parent.visible + Accessible.onPressAction : { + window.close() + } + } + } + } +} diff --git a/internal/frontend/qml/ProtonUI/qmldir b/internal/frontend/qml/ProtonUI/qmldir new file mode 100644 index 00000000..afe0b94a --- /dev/null +++ b/internal/frontend/qml/ProtonUI/qmldir @@ -0,0 +1,31 @@ +module ProtonUI +singleton Style 1.0 Style.qml +AccessibleButton 1.0 AccessibleButton.qml +AccessibleText 1.0 AccessibleText.qml +AccessibleSelectableText 1.0 AccessibleSelectableText.qml +AccountView 1.0 AccountView.qml +AddAccountBar 1.0 AddAccountBar.qml +BubbleNote 1.0 BubbleNote.qml +BugReportWindow 1.0 BugReportWindow.qml +ButtonIconText 1.0 ButtonIconText.qml +ButtonRounded 1.0 ButtonRounded.qml +CheckBoxLabel 1.0 CheckBoxLabel.qml +ClickIconText 1.0 ClickIconText.qml +Dialog 1.0 Dialog.qml +DialogAddUser 1.0 DialogAddUser.qml +DialogUpdate 1.0 DialogUpdate.qml +DialogConnectionTroubleshoot 1.0 DialogConnectionTroubleshoot.qml +FileAndFolderSelect 1.0 FileAndFolderSelect.qml +InfoToolTip 1.0 InfoToolTip.qml +InformationBar 1.0 InformationBar.qml +InputBox 1.0 InputBox.qml +InputField 1.0 InputField.qml +InstanceExistsWindow 1.0 InstanceExistsWindow.qml +LogoHeader 1.0 LogoHeader.qml +PopupMessage 1.0 PopupMessage.qml +TabButton 1.0 TabButton.qml +TabLabels 1.0 TabLabels.qml +TextLabel 1.0 TextLabel.qml +TextValue 1.0 TextValue.qml +TLSCertPinIssueBar 1.0 TLSCertPinIssueBar.qml +WindowTitleBar 1.0 WindowTitleBar.qml diff --git a/internal/frontend/qml/tst_Gui.qml b/internal/frontend/qml/tst_Gui.qml new file mode 100644 index 00000000..5b790aaf --- /dev/null +++ b/internal/frontend/qml/tst_Gui.qml @@ -0,0 +1,611 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +import QtQuick 2.8 +import QtTest 1.2 +import BridgeUI 1.0 +import ProtonUI 1.0 +import QtQuick.Controls 2.1 +import QtQuick.Window 2.2 + +Window { + id: testroot + width : 150 + height : 600 + flags : Qt.Window | Qt.Dialog | Qt.FramelessWindowHint + visible : true + title : "GUI test Window" + color : "transparent" + + property bool newVersion : true + + Accessible.name: testroot.title + Accessible.description: "Window with buttons testing the GUI events" + + + Rectangle { + id:test_systray + anchors{ + top: parent.top + horizontalCenter: parent.horizontalCenter + } + height: 40 + width: testroot.width + color: "yellow" + Image { + id: sysImg + anchors { + left : test_systray.left + top : test_systray.top + } + height: test_systray.height + mipmap: true + fillMode : Image.PreserveAspectFit + source: "" + } + Text { + id: systrText + anchors { + right : test_systray.right + verticalCenter: test_systray.verticalCenter + } + text: "unset" + } + + function normal() { + test_systray.color = "#22ee22" + systrText.text = "norm" + sysImg.source= "../share/icons/black-systray.png" + } + function highlight() { + test_systray.color = "#eeee22" + systrText.text = "highl" + sysImg.source= "../share/icons/black-syswarn.png" + } + function error() { + test_systray.color = "#ee2222" + systrText.text = "error" + sysImg.source= "../share/icons/black-syserror.png" + } + + MouseArea { + property point diff: "0,0" + anchors.fill: parent + onPressed: { + diff = Qt.point(testroot.x, testroot.y) + var mousePos = mapToGlobal(mouse.x, mouse.y) + diff.x -= mousePos.x + diff.y -= mousePos.y + } + onPositionChanged: { + var currPos = mapToGlobal(mouse.x, mouse.y) + testroot.x = currPos.x + diff.x + testroot.y = currPos.y + diff.y + } + } + } + + ListModel { + id: buttons + + ListElement { title: "Show window" } + ListElement { title: "Show help" } + ListElement { title: "Show quit" } + ListElement { title: "Logout bridge" } + ListElement { title: "Internet on" } + ListElement { title: "Internet off" } + ListElement { title: "NeedUpdate" } + ListElement { title: "UpToDate" } + ListElement { title: "ForceUpdate" } + ListElement { title: "Linux" } + ListElement { title: "Windows" } + ListElement { title: "Macos" } + ListElement { title: "FirstDialog" } + ListElement { title: "AutostartError" } + ListElement { title: "BusyPortIMAP" } + ListElement { title: "BusyPortSMTP" } + ListElement { title: "BusyPortBOTH" } + ListElement { title: "Minimize this" } + ListElement { title: "SendAlertPopup" } + ListElement { title: "TLSCertError" } + } + + ListView { + id: view + anchors { + top : test_systray.bottom + bottom : parent.bottom + left : parent.left + right : parent.right + } + + orientation : ListView.Vertical + model : buttons + focus : true + + delegate : ButtonRounded { + text : title + color_main : "orange" + color_minor : "#aa335588" + isOpaque : true + width: testroot.width + height : 20*Style.px + anchors.horizontalCenter: parent.horizontalCenter + onClicked : { + console.log("Clicked on ", title) + switch (title) { + case "Show window" : + go.showWindow(); + break; + case "Show help" : + go.showHelp(); + break; + case "Show quit" : + go.showQuit(); + break; + case "Logout bridge" : + go.checkLoggedOut("bridge"); + break; + case "Internet on" : + go.setConnectionStatus(true); + break; + case "Internet off" : + go.setConnectionStatus(false); + break; + case "Linux" : + go.goos = "linux"; + break; + case "Macos" : + go.goos = "darwin"; + break; + case "Windows" : + go.goos = "windows"; + break; + case "FirstDialog" : + testgui.winMain.dialogFirstStart.show(); + break; + case "AutostartError" : + go.notifyBubble(1,go.failedAutostart); + break; + case "BusyPortIMAP" : + go.notifyPortIssue(true,false); + break; + case "BusyPortSMTP" : + go.notifyPortIssue(false,true); + break; + case "BusyPortBOTH" : + go.notifyPortIssue(true,true); + break; + case "Minimize this" : + testroot.visibility = Window.Minimized + break; + case "UpToDate" : + testroot.newVersion = false + break; + case "NeedUpdate" : + testroot.newVersion = true + break; + case "ForceUpdate" : + go.notifyUpdate() + break; + case "SendAlertPopup" : + go.showOutgoingNoEncPopup("Alert sending unencrypted!") + break; + case "TLSCertError" : + go.showCertIssue() + break; + default : + console.log("Not implemented " + data) + } + } + } + } + + + Component.onCompleted : { + testroot.x= 10 + testroot.y= 100 + } + + //InstanceExistsWindow { id: ie_test } + + Gui { + id: testgui + + ListModel{ + id: accountsModel + ListElement{ account : "bridge" ; status : "connected"; isExpanded: false; isCombinedAddressMode: false; hostname : "127.0.0.1"; password : "ZI9tKp+ryaxmbpn2E12"; security : "StarTLS"; portSMTP : 1025; portIMAP : 1143; aliases : "bridge@pm.com;bridge2@pm.com;theHorriblySlowMurderWithExtremelyInefficientWeapon@youtube.com" } + ListElement{ account : "exteremelongnamewhichmustbeeladed@protonmail.com" ; status : "connected"; isExpanded: true; isCombinedAddressMode: true; hostname : "127.0.0.1"; password : "ZI9tKp+ryaxmbpn2E12"; security : "StarTLS"; portSMTP : 1025; portIMAP : 1143; aliases : "bridge@pm.com;bridge2@pm.com;hu@hu.hu" } + ListElement{ account : "bridge2@protonmail.com" ; status : "disconnected"; isExpanded: false; isCombinedAddressMode: false; hostname : "127.0.0.1"; password : "ZI9tKp+ryaxmbpn2E12"; security : "StarTLS"; portSMTP : 1025; portIMAP : 1143; aliases : "bridge@pm.com;bridge2@pm.com;hu@hu.hu" } + } + + Component.onCompleted : { + winMain.x = testroot.x + testroot.width + winMain.y = testroot.y + } + } + + + QtObject { + id: go + + property bool isAutoStart : true + property bool isProxyAllowed : false + property bool isFirstStart : false + property bool isFreshVersion : false + property bool isOutdateVersion : true + property string currentAddress : "none" + //property string goos : "windows" + property string goos : "linux" + ////property string goos : "darwin" + property bool isDefaultPort : false + property bool isShownOnStart : true + + property bool hasNoKeychain : true + + property string wrongCredentials + property string wrongMailboxPassword + property string canNotReachAPI + property string versionCheckFailed + property string credentialsNotRemoved + property string bugNotSent + property string bugReportSent + property string failedAutostartPerm + property string failedAutostart + property string genericErrSeeLogs + + property string programTitle : "ProtonMail Bridge" + property string newversion : "QA.1.0" + property string fullversion : "QA.1.0 (d9f8sdf9) 2020-02-19T10:57:23+01:00" + property string landingPage : "https://landing.page" + //property string downloadLink: "https://landing.page/download/link" + property string downloadLink: "https://protonmail.com/download/beta/protonmail-bridge-1.1.5-1.x86_64.rpm;https://www.protonmail.com/downloads/beta/Desktop-Bridge-link1.exe;https://www.protonmail.com/downloads/beta/Desktop-Bridge-link1.exe;https://www.protonmail.com/downloads/beta/Desktop-Bridge-link1.exe;" + //property string changelog : "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet." + property string changelog : "• Support of encryption to external PGP recipients using contacts created on beta.protonmail.com (see https://protonmail.com/blog/pgp-vulnerability-efail/ to understand the vulnerabilities that may be associated with sending to other PGP clients)\n• Notification that outgoing email will be delivered as non-encrypted.\n• NOTE: Due to a change of the keychain format, you will need to add your account(s) to the Bridge after installing this version" + property string bugfixes : "• Support accounts with same user names\n• Support sending vCalendar event" + property string credits : "here;goes;list;;of;;used;packages;" + + property real progress: 0.3 + property int progressDescription: 2 + + + signal toggleMainWin(int systX, int systY, int systW, int systH) + + signal showWindow() + signal showHelp() + signal showQuit() + + signal notifyPortIssue(bool busyPortIMAP, bool busyPortSMTP) + signal notifyVersionIsTheLatest() + signal setUpdateState(string updateState) + signal notifyKeychainRebuild() + signal notifyHasNoKeychain() + + signal processFinished() + signal toggleAutoStart() + signal notifyBubble(int tabIndex, string message) + signal silentBubble(int tabIndex, string message) + signal runCheckVersion(bool showMessage) + signal setAddAccountWarning(string message) + + signal notifyUpdate() + signal notifyFirewall() + signal notifyLogout(string accname) + signal notifyAddressChanged(string accname) + signal notifyAddressChangedLogout(string accname) + signal failedAutostartCode(string code) + + signal showCertIssue() + + signal updateFinished(bool hasError) + + + signal showOutgoingNoEncPopup(string subject) + signal setOutgoingNoEncPopupCoord(real x, real y) + signal showNoActiveKeyForRecipient(string recipient) + + function delay(duration) { + var timeStart = new Date().getTime(); + + while (new Date().getTime() - timeStart < duration) { + // Do nothing + } + } + + function getLastMailClient() { + return "Mutt is the best" + } + + function sendBug(desc,client,address){ + console.log("bug report ", "desc '"+desc+"'", "client '"+client+"'", "address '"+address+"'") + return !desc.includes("fail") + } + + function deleteAccount(index,remove) { + console.log ("Test: Delete account ",index," and remove prefences "+remove) + workAndClose() + accountsModel.remove(index) + } + + function logoutAccount(index) { + accountsModel.get(index).status="disconnected" + workAndClose() + } + + function login(username,password) { + delay(700) + if (password=="wrong") { + setAddAccountWarning("Wrong password") + return -1 + } + if (username=="2fa") { + return 1 + } + if (username=="mbox") { + return 2 + } + return 0 + } + + function auth2FA(twoFACode){ + delay(700) + if (twoFACode=="wrong") { + setAddAccountWarning("Wrong 2FA") + return -1 + } + if (twoFACode=="mbox") { + return 1 + } + return 0 + } + + function addAccount(mailboxPass) { + delay(700) + if (mailboxPass=="wrong") { + setAddAccountWarning("Wrong mailbox password") + return -1 + } + accountsModel.append({ + "account" : testgui.winMain.dialogAddUser.username, + "status" : "connected", + "isExpanded":true, + "hostname" : "127.0.0.1", + "password" : "ZI9tKp+ryaxmbpn2E12", + "security" : "StarTLS", + "portSMTP" : 1025, + "portIMAP" : 1143, + "aliases" : "bridge@pm.com;bridges@pm.com;theHorriblySlowMurderWithExtremelyInefficientWeapon@youtube.com", + "isCombinedAddressMode": true + }) + workAndClose() + } + + function checkInternet() { + var delay = Qt.createQmlObject("import QtQuick 2.8; Timer{}",go) + delay.interval = 2000 + delay.repeat = false + delay.triggered.connect(function(){ go.setConnectionStatus(false) }) + delay.start() + } + + property SequentialAnimation animateProgressBar : SequentialAnimation { + // version + PropertyAnimation{ target: go; properties: "progressDescription"; to: 1; duration: 1; } + PropertyAnimation{ duration: 2000; } + + // download + PropertyAnimation{ target: go; properties: "progressDescription"; to: 2; duration: 1; } + PropertyAnimation{ duration: 500; } + PropertyAnimation{ target: go; properties: "progress"; to: 0.01; duration: 1; } + PropertyAnimation{ duration: 500; } + PropertyAnimation{ target: go; properties: "progress"; to: 0.1; duration: 1; } + PropertyAnimation{ duration: 500; } + PropertyAnimation{ target: go; properties: "progress"; to: 0.3; duration: 1; } + PropertyAnimation{ duration: 500; } + PropertyAnimation{ target: go; properties: "progress"; to: 0.5; duration: 1; } + PropertyAnimation{ duration: 500; } + PropertyAnimation{ target: go; properties: "progress"; to: 0.8; duration: 1; } + PropertyAnimation{ duration: 500; } + PropertyAnimation{ target: go; properties: "progress"; to: 1.0; duration: 1; } + PropertyAnimation{ duration: 1000; } + + // verify + PropertyAnimation{ target: go; properties: "progress"; to: 0.0; duration: 1; } + PropertyAnimation{ target: go; properties: "progressDescription"; to: 3; duration: 1; } + PropertyAnimation{ duration: 2000; } + + // unzip + PropertyAnimation{ target: go; properties: "progressDescription"; to: 4; duration: 1; } + PropertyAnimation{ duration: 500; } + PropertyAnimation{ target: go; properties: "progress"; to: 0.01; duration: 1; } + PropertyAnimation{ duration: 500; } + PropertyAnimation{ target: go; properties: "progress"; to: 0.1; duration: 1; } + PropertyAnimation{ duration: 500; } + PropertyAnimation{ target: go; properties: "progress"; to: 0.3; duration: 1; } + PropertyAnimation{ duration: 500; } + PropertyAnimation{ target: go; properties: "progress"; to: 0.5; duration: 1; } + PropertyAnimation{ duration: 500; } + PropertyAnimation{ target: go; properties: "progress"; to: 0.8; duration: 1; } + PropertyAnimation{ duration: 500; } + PropertyAnimation{ target: go; properties: "progress"; to: 1.0; duration: 1; } + PropertyAnimation{ duration: 2000; } + + // update + PropertyAnimation{ target: go; properties: "progress"; to: 0.0; duration: 1; } + PropertyAnimation{ target: go; properties: "progressDescription"; to: 5; duration: 1; } + PropertyAnimation{ duration: 2000; } + + // quit + PropertyAnimation{ target: go; properties: "progressDescription"; to: 6; duration: 1; } + PropertyAnimation{ duration: 2000; } + + } + + property Timer timer : Timer { + id: timer + interval : 700 + repeat : false + property string work + onTriggered : { + console.log("triggered "+timer.work) + switch (timer.work) { + case "wait": + break + case "startUpdate": + go.animateProgressBar.start() + go.updateFinished(true) + default: + go.processFinished() + } + } + } + function workAndClose() { + timer.work="default" + timer.start() + } + + function startUpdate() { + timer.work="startUpdate" + timer.start() + } + + function loadAccounts() { + console.log("Test: Account loaded") + } + + + function openDownloadLink(){ + } + + function switchAddressMode(username){ + for (var iAcc=0; iAcc < accountsModel.count; iAcc++) { + if (accountsModel.get(iAcc).account == username ) { + accountsModel.get(iAcc).isCombinedAddressMode = !accountsModel.get(iAcc).isCombinedAddressMode + break + } + } + workAndClose() + } + + function getLocalVersionInfo(){ + go.newversion = "QA.1.0" + } + + function isNewVersionAvailable(showMessage){ + if (testroot.newVersion) { + go.newversion = "QA.2.0" + setUpdateState("oldVersion") + } else { + go.newversion = "QA.1.0" + setUpdateState("upToDate") + if(showMessage) { + notifyVersionIsTheLatest() + } + } + workAndClose() + } + + function getBackendVersion() { + return "BridgeUI 1.0" + } + + property bool isConnectionOK : true + signal setConnectionStatus(bool isAvailable) + + function configureAppleMail(iAccount,iAddress) { + console.log ("Test: autoconfig account ",iAccount," address ",iAddress) + } + + function openLogs() { + Qt.openUrlExternally("file:///home/dev/") + } + + function highlightSystray() { + test_systray.highlight() + } + + function errorSystray() { + test_systray.error() + } + + function normalSystray() { + test_systray.normal() + } + + signal bubbleClosed() + + function getIMAPPort() { + return 1143 + } + function getSMTPPort() { + return 1025 + } + + function isPortOpen(portstring){ + if (isNaN(portstring)) { + return 1 + } + var portnum = parseInt(portstring,10) + if (portnum < 3333) { + return 1 + } + return 0 + } + + property bool isRestarting: false + function setPortsAndSecurity(portIMAP, portSMTP, secSMTP) { + console.log("Test: ports changed", portIMAP, portSMTP, secSMTP) + } + + function isSMTPSTARTTLS() { + return true + } + + signal openManual() + + function clearCache() { + workAndClose() + } + + function clearKeychain() { + workAndClose() + } + + property bool isReportingOutgoingNoEnc : true + + function toggleIsReportingOutgoingNoEnc() { + go.isReportingOutgoingNoEnc = !go.isReportingOutgoingNoEnc + console.log("Reporting changed to ", go.isReportingOutgoingNoEnc) + } + + function saveOutgoingNoEncPopupCoord(x,y) { + console.log("Triggered saveOutgoingNoEncPopupCoord: ",x,y) + } + + function shouldSendAnswer (messageID, shouldSend) { + if (shouldSend) console.log("answered to send email") + else console.log("answered to cancel email") + } + + onToggleAutoStart: { + workAndClose() + isAutoStart = (isAutoStart!=false) ? false : true + console.log (" Test: toggleAutoStart "+isAutoStart) + } + } +} + diff --git a/internal/frontend/qt/Makefile.local b/internal/frontend/qt/Makefile.local new file mode 100644 index 00000000..a50f35bd --- /dev/null +++ b/internal/frontend/qt/Makefile.local @@ -0,0 +1,64 @@ +QMLfiles=$(shell find ../qml/ -name "*.qml") $(shell find ../qml/ -name "qmldir") +FontAwesome=${CURDIR}/../share/fontawesome-webfont.ttf +ImageDir=${CURDIR}/../share/icons +Icons=$(shell find ${ImageDir} -name "*.png") + +all: qmlcheck moc.go rcc.cpp logo.ico + +deploy: + qtdeploy build desktop + +../qml/ProtonUI/fontawesome.ttf: + ln -sf ${FontAwesome} $@ +../qml/ProtonUI/images: + ln -sf ${ImageDir} $@ + +translate.ts: ${QMLfiles} + lupdate -recursive qml/ -ts $@ + +rcc.cpp: ${QMLfiles} ${Icons} resources.qrc + rm -f rcc.cpp rcc.qrc && qtrcc -o . + + +qmltest: + qmltestrunner -eventdelay 500 -import ./qml/ +qmlcheck : ../qml/ProtonUI/fontawesome.ttf ../qml/ProtonUI/images + qmlscene -I ../qml/ -f ../qml/tst_Gui.qml --quit +qmlpreview : ../qml/ProtonUI/fontawesome.ttf ../qml/ProtonUI/images + rm -f ../qml/*.qmlc ../qml/BridgeUI/*.qmlc + qmlscene -verbose -I ../qml/ -f ../qml/tst_Gui.qml + #qmlscene -qmljsdebugger=port:3768,block -verbose -I ../qml/ -f ../qml/tst_Gui.qml + +logo.ico: ../share/icons/logo.ico + cp $^ $@ + + +test: qmlcheck moc.go rcc.cpp + go test -v -tags=cli + +moc.go: ui.go accountModel.go + qtmoc + +distclean: clean + rm -rf rcc_cgo*.go + +clean: + rm -rf linux/ + rm -rf darwin/ + rm -rf windows/ + rm -rf deploy/ + rm -f logo.ico + rm -f moc.cpp + rm -f moc.go + rm -f moc.h + rm -f moc_cgo*.go + rm -f moc_moc.h + rm -f rcc.cpp + rm -f rcc.qrc + rm -f rcc_cgo*.go + rm -f ../rcc.cpp + rm -f ../rcc.qrc + rm -f ../rcc_cgo*.go + rm -rf ../qml/ProtonUI/images + rm -f ../qml/ProtonUI/fontawesome.ttf + find ../qml -name *.qmlc -exec rm {} \; diff --git a/internal/frontend/qt/accountModel.go b/internal/frontend/qt/accountModel.go new file mode 100644 index 00000000..d094fe01 --- /dev/null +++ b/internal/frontend/qt/accountModel.go @@ -0,0 +1,240 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build !nogui + +package qt + +import ( + "fmt" + + "github.com/therecipe/qt/core" +) + +// The element of model. +// It contains all data for one account and its aliases. +type AccountInfo struct { + core.QObject + + _ string `property:"account"` + _ string `property:"userID"` + _ string `property:"status"` + _ string `property:"hostname"` + _ string `property:"password"` + _ string `property:"security"` // Deprecated, not used. + _ int `property:"portSMTP"` + _ int `property:"portIMAP"` + _ string `property:"aliases"` + _ bool `property:"isExpanded"` + _ bool `property:"isCombinedAddressMode"` +} + +// Constants for data map. +// enum-like constants in Go. +const ( + Account = int(core.Qt__UserRole) + 1<= len(s.Accounts()) { + return NewAccountInfo(nil) + } else { + return s.Accounts()[index] + } +} + +// data is a getter for account info data. +func (s *AccountsModel) data(index *core.QModelIndex, role int) *core.QVariant { + if !index.IsValid() { + return core.NewQVariant() + } + + if index.Row() >= len(s.Accounts()) { + return core.NewQVariant() + } + + var p = s.Accounts()[index.Row()] + + switch role { + case Account: + return NewQVariantString(p.Account()) + case UserID: + return NewQVariantString(p.UserID()) + case Status: + return NewQVariantString(p.Status()) + case Hostname: + return NewQVariantString(p.Hostname()) + case Password: + return NewQVariantString(p.Password()) + case Security: + return NewQVariantString(p.Security()) + case PortIMAP: + return NewQVariantInt(p.PortIMAP()) + case PortSMTP: + return NewQVariantInt(p.PortSMTP()) + case Aliases: + return NewQVariantString(p.Aliases()) + case IsExpanded: + return NewQVariantBool(p.IsExpanded()) + case IsCombinedAddressMode: + return NewQVariantBool(p.IsCombinedAddressMode()) + default: + return core.NewQVariant() + } +} + +// rowCount returns the dimension of model: number of rows is equivalent to number of items in list. +func (s *AccountsModel) rowCount(parent *core.QModelIndex) int { + return len(s.Accounts()) +} + +// columnCount returns the dimension of model: AccountsModel has only one column. +func (s *AccountsModel) columnCount(parent *core.QModelIndex) int { + return 1 +} + +// roleNames returns the names of available item properties. +func (s *AccountsModel) roleNames() map[int]*core.QByteArray { + return s.Roles() +} + +// addAccount is connected to the addAccount slot. +func (s *AccountsModel) addAccount(p *AccountInfo) { + s.BeginInsertRows(core.NewQModelIndex(), len(s.Accounts()), len(s.Accounts())) + s.SetAccounts(append(s.Accounts(), p)) + s.SetCount(len(s.Accounts())) + s.EndInsertRows() +} + +// Method connected to toggleIsAvailable slot. +func (s *AccountsModel) toggleIsAvailable(row int) { + var p = s.Accounts()[row] + currentStatus := p.Status() + if currentStatus == "active" { + p.SetStatus("disabled") + } else if currentStatus == "disabled" { + p.SetStatus("active") + } else { + p.SetStatus("error") + } + var pIndex = s.Index(row, 0, core.NewQModelIndex()) + s.DataChanged(pIndex, pIndex, []int{Status}) +} + +// Method connected to removeAccount slot. +func (s *AccountsModel) removeAccount(row int) { + s.BeginRemoveRows(core.NewQModelIndex(), row, row) + s.SetAccounts(append(s.Accounts()[:row], s.Accounts()[row+1:]...)) + s.SetCount(len(s.Accounts())) + s.EndRemoveRows() +} + +// Remove all items in model. +func (s *AccountsModel) Clear() { + s.BeginRemoveRows(core.NewQModelIndex(), 0, len(s.Accounts())) + s.SetAccounts(s.Accounts()[0:0]) + s.SetCount(len(s.Accounts())) + s.EndRemoveRows() +} + +// Print the content of account models to console. +func (s *AccountsModel) Dump() { + fmt.Printf("Dimensions rows %d cols %d\n", s.rowCount(nil), s.columnCount(nil)) + for iAcc := 0; iAcc < s.rowCount(nil); iAcc++ { + var p = s.Accounts()[iAcc] + fmt.Printf(" %d. %s\n", iAcc, p.Account()) + } +} diff --git a/internal/frontend/qt/accounts.go b/internal/frontend/qt/accounts.go new file mode 100644 index 00000000..21f2118e --- /dev/null +++ b/internal/frontend/qt/accounts.go @@ -0,0 +1,213 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build !nogui + +package qt + +import ( + "fmt" + "strings" + + "github.com/ProtonMail/proton-bridge/internal/bridge" + "github.com/ProtonMail/proton-bridge/internal/events" + "github.com/ProtonMail/proton-bridge/internal/preferences" + "github.com/ProtonMail/proton-bridge/pkg/keychain" + pmapi "github.com/ProtonMail/proton-bridge/pkg/pmapi" +) + +func (s *FrontendQt) loadAccounts() { + accountMutex.Lock() + defer accountMutex.Unlock() + + // Update users. + s.Accounts.Clear() + + users := s.bridge.GetUsers() + + // If there are no active accounts. + if len(users) == 0 { + log.Info("No active accounts") + return + } + for _, user := range users { + acc_info := NewAccountInfo(nil) + username := user.Username() + if username == "" { + username = user.ID() + } + acc_info.SetAccount(username) + + // Set status. + if user.IsConnected() { + acc_info.SetStatus("connected") + } else { + acc_info.SetStatus("disconnected") + } + + // Set login info. + acc_info.SetUserID(user.ID()) + acc_info.SetHostname(bridge.Host) + acc_info.SetPassword(user.GetBridgePassword()) + acc_info.SetPortIMAP(s.preferences.GetInt(preferences.IMAPPortKey)) + acc_info.SetPortSMTP(s.preferences.GetInt(preferences.SMTPPortKey)) + + // Set aliases. + acc_info.SetAliases(strings.Join(user.GetAddresses(), ";")) + acc_info.SetIsExpanded(user.ID() == s.userIDAdded) + acc_info.SetIsCombinedAddressMode(user.IsCombinedAddressMode()) + + s.Accounts.addAccount(acc_info) + } + + // Updated can clear. + s.userIDAdded = "" +} + +func (s *FrontendQt) clearCache() { + defer s.Qml.ProcessFinished() + if err := s.bridge.ClearData(); err != nil { + log.Error("While clearing cache: ", err) + } + // Clearing data removes everything (db, preferences, ...) + // so everything has to be stopped and started again. + s.Qml.SetIsRestarting(true) + s.App.Quit() +} + +func (s *FrontendQt) clearKeychain() { + defer s.Qml.ProcessFinished() + for _, user := range s.bridge.GetUsers() { + if err := s.bridge.DeleteUser(user.ID(), false); err != nil { + log.Error("While deleting user: ", err) + if err == keychain.ErrNoKeychainInstalled { // Probably not needed anymore. + s.Qml.NotifyHasNoKeychain() + } + } + } +} + +func (s *FrontendQt) logoutAccount(iAccount int) { + defer s.Qml.ProcessFinished() + userID := s.Accounts.get(iAccount).UserID() + user, err := s.bridge.GetUser(userID) + if err != nil { + log.Error("While logging out ", userID, ": ", err) + return + } + if err := user.Logout(); err != nil { + log.Error("While logging out ", userID, ": ", err) + } +} + +func (s *FrontendQt) showLoginError(err error, scope string) bool { + if err == nil { + s.Qml.SetConnectionStatus(true) // If we are here connection is ok. + return false + } + log.Warnf("%s: %v", scope, err) + if err == pmapi.ErrAPINotReachable { + s.Qml.SetConnectionStatus(false) + s.SendNotification(TabAccount, s.Qml.CanNotReachAPI()) + s.Qml.ProcessFinished() + return true + } + s.Qml.SetConnectionStatus(true) // If we are here connection is ok. + if err == pmapi.ErrUpgradeApplication { + s.eventListener.Emit(events.UpgradeApplicationEvent, "") + return true + } + s.Qml.SetAddAccountWarning(err.Error(), -1) + return true +} + +// login returns: +// -1: when error occurred +// 0: when no 2FA and no MBOX +// 1: when has 2FA +// 2: when has no 2FA but have MBOX +func (s *FrontendQt) login(login, password string) int { + var err error + s.authClient, s.auth, err = s.bridge.Login(login, password) + if s.showLoginError(err, "login") { + return -1 + } + if s.auth.HasTwoFactor() { + return 1 + } + if s.auth.HasMailboxPassword() { + return 2 + } + return 0 // No 2FA, no mailbox password. +} + +// auth2FA returns: +// -1 : error (use SetAddAccountWarning to show message) +// 0 : single password mode +// 1 : two password mode +func (s *FrontendQt) auth2FA(twoFacAuth string) int { + var err error + if s.auth == nil || s.authClient == nil { + err = fmt.Errorf("missing authentication in auth2FA %p %p", s.auth, s.authClient) + } else { + _, err = s.authClient.Auth2FA(twoFacAuth, s.auth) + } + + if s.showLoginError(err, "auth2FA") { + return -1 + } + + if s.auth.HasMailboxPassword() { + return 1 // Ask for mailbox password. + } + return 0 // One password. +} + +// addAccount adds an account. It should close login modal ProcessFinished if ok. +func (s *FrontendQt) addAccount(mailboxPassword string) int { + if s.auth == nil || s.authClient == nil { + log.Errorf("Missing authentication in addAccount %p %p", s.auth, s.authClient) + s.Qml.SetAddAccountWarning(s.Qml.WrongMailboxPassword(), -2) + return -1 + } + + user, err := s.bridge.FinishLogin(s.authClient, s.auth, mailboxPassword) + if err != nil { + log.WithError(err).Error("Login was unsuccessful") + s.Qml.SetAddAccountWarning("Failure: "+err.Error(), -2) + return -1 + } + + s.userIDAdded = user.ID() + s.eventListener.Emit(events.UserRefreshEvent, user.ID()) + s.Qml.ProcessFinished() + return 0 +} + +func (s *FrontendQt) deleteAccount(iAccount int, removePreferences bool) { + defer s.Qml.ProcessFinished() + userID := s.Accounts.get(iAccount).UserID() + if err := s.bridge.DeleteUser(userID, removePreferences); err != nil { + log.Warn("deleteUser: cannot remove user: ", err) + if err == keychain.ErrNoKeychainInstalled { + s.Qml.NotifyHasNoKeychain() + return + } + s.SendNotification(TabSettings, err.Error()) + return + } +} diff --git a/internal/frontend/qt/frontend.go b/internal/frontend/qt/frontend.go new file mode 100644 index 00000000..00010fca --- /dev/null +++ b/internal/frontend/qt/frontend.go @@ -0,0 +1,645 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build !nogui + +// Package qt is the Qt User interface for Desktop bridge. +// +// The FrontendQt implements Frontend interface: `frontend.go`. +// The helper functions are in `helpers.go`. +// Notification specific is written in `notification.go`. +// The AccountsModel is container providing account info to QML ListView. +// +// Since we are using QML there is only one Qt loop in `ui.go`. +package qt + +import ( + "errors" + "os" + "runtime" + "strconv" + "strings" + "sync" + "time" + + "github.com/ProtonMail/proton-bridge/internal/bridge" + "github.com/ProtonMail/proton-bridge/internal/events" + "github.com/ProtonMail/proton-bridge/internal/frontend/autoconfig" + "github.com/ProtonMail/proton-bridge/internal/frontend/types" + "github.com/ProtonMail/proton-bridge/internal/preferences" + "github.com/ProtonMail/proton-bridge/pkg/config" + "github.com/ProtonMail/proton-bridge/pkg/ports" + "github.com/ProtonMail/proton-bridge/pkg/useragent" + "github.com/ProtonMail/go-autostart" + + //"github.com/ProtonMail/proton-bridge/pkg/keychain" + "github.com/ProtonMail/proton-bridge/pkg/listener" + pmapi "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/ProtonMail/proton-bridge/pkg/updates" + "github.com/kardianos/osext" + "github.com/skratchdot/open-golang/open" + "github.com/therecipe/qt/core" + "github.com/therecipe/qt/gui" + "github.com/therecipe/qt/qml" + "github.com/therecipe/qt/widgets" +) + +var log = config.GetLogEntry("frontend-qt") +var accountMutex = &sync.Mutex{} + +// API between Bridge and Qt. +// +// With this interface it is possible to control Qt-Gui interface using pointers to +// Qt and QML objects. QML signals and slots are connected via methods of GoQMLInterface. +type FrontendQt struct { + version string + buildVersion string + showWindowOnStart bool + panicHandler types.PanicHandler + config *config.Config + preferences *config.Preferences + eventListener listener.Listener + updates types.Updater + bridge types.Bridger + noEncConfirmator types.NoEncConfirmator + + App *widgets.QApplication // Main Application pointer. + View *qml.QQmlApplicationEngine // QML engine pointer. + MainWin *core.QObject // Pointer to main window inside QML. + Qml *GoQMLInterface // Object accessible from both Go and QML for methods and signals. + Accounts *AccountsModel // Providing data for accounts ListView. + programName string // Program name (shown in taskbar). + programVer string // Program version (shown in help). + + authClient bridge.PMAPIProvider + + auth *pmapi.Auth + + AutostartEntry *autostart.App + + // expand userID when added + userIDAdded string + + notifyHasNoKeychain bool +} + +// New returns a new Qt frontendend for the bridge. +func New( + version, + buildVersion string, + showWindowOnStart bool, + panicHandler types.PanicHandler, + config *config.Config, + preferences *config.Preferences, + eventListener listener.Listener, + updates types.Updater, + bridge types.Bridger, + noEncConfirmator types.NoEncConfirmator, +) *FrontendQt { + prgName := "ProtonMail Bridge" + tmp := &FrontendQt{ + version: version, + buildVersion: buildVersion, + showWindowOnStart: showWindowOnStart, + panicHandler: panicHandler, + config: config, + preferences: preferences, + eventListener: eventListener, + updates: updates, + bridge: bridge, + noEncConfirmator: noEncConfirmator, + + programName: prgName, + programVer: "v" + version, + AutostartEntry: &autostart.App{ + Name: prgName, + DisplayName: prgName, + Exec: []string{"", "--no-window"}, + }, + } + + // Handle autostart if wanted. + if p, err := osext.Executable(); err == nil { + tmp.AutostartEntry.Exec[0] = p + log.Info("Autostart ", p) + } else { + log.Error("Cannot get current executable path: ", err) + } + + // Nicer string for OS. + currentOS := core.QSysInfo_PrettyProductName() + bridge.SetCurrentOS(currentOS) + + return tmp +} + +// InstanceExistAlert is a global warning window indicating an instance already exists. +func (s *FrontendQt) InstanceExistAlert() { + log.Warn("Instance already exists") + s.QtSetupCoreAndControls() + s.App = widgets.NewQApplication(len(os.Args), os.Args) + s.View = qml.NewQQmlApplicationEngine(s.App) + s.View.AddImportPath("qrc:///") + s.View.Load(core.NewQUrl3("qrc:/BridgeUI/InstanceExistsWindow.qml", 0)) + _ = gui.QGuiApplication_Exec() +} + +// Loop function for Bridge interface. +// +// It runs QtExecute in main thread with no additional function. +func (s *FrontendQt) Loop(credentialsError error) (err error) { + if credentialsError != nil { + s.notifyHasNoKeychain = true + } + go func() { + defer s.panicHandler.HandlePanic() + s.watchEvents() + }() + err = s.qtExecute(func(s *FrontendQt) error { return nil }) + return err +} + +func (s *FrontendQt) watchEvents() { + errorCh := s.getEventChannel(events.ErrorEvent) + outgoingNoEncCh := s.getEventChannel(events.OutgoingNoEncEvent) + noActiveKeyForRecipientCh := s.getEventChannel(events.NoActiveKeyForRecipientEvent) + internetOffCh := s.getEventChannel(events.InternetOffEvent) + internetOnCh := s.getEventChannel(events.InternetOnEvent) + secondInstanceCh := s.getEventChannel(events.SecondInstanceEvent) + restartBridgeCh := s.getEventChannel(events.RestartBridgeEvent) + addressChangedCh := s.getEventChannel(events.AddressChangedEvent) + addressChangedLogoutCh := s.getEventChannel(events.AddressChangedLogoutEvent) + logoutCh := s.getEventChannel(events.LogoutEvent) + updateApplicationCh := s.getEventChannel(events.UpgradeApplicationEvent) + newUserCh := s.getEventChannel(events.UserRefreshEvent) + certIssue := s.getEventChannel(events.TLSCertIssue) + for { + select { + case errorDetails := <-errorCh: + imapIssue := strings.Contains(errorDetails, "IMAP failed") + smtpIssue := strings.Contains(errorDetails, "SMTP failed") + s.Qml.NotifyPortIssue(imapIssue, smtpIssue) + case idAndSubject := <-outgoingNoEncCh: + idAndSubjectSlice := strings.SplitN(idAndSubject, ":", 2) + messageID := idAndSubjectSlice[0] + subject := idAndSubjectSlice[1] + s.Qml.ShowOutgoingNoEncPopup(messageID, subject) + case email := <-noActiveKeyForRecipientCh: + s.Qml.ShowNoActiveKeyForRecipient(email) + case <-internetOffCh: + s.Qml.SetConnectionStatus(false) + case <-internetOnCh: + s.Qml.SetConnectionStatus(true) + case <-secondInstanceCh: + s.Qml.ShowWindow() + case <-restartBridgeCh: + s.Qml.SetIsRestarting(true) + s.App.Quit() + case address := <-addressChangedCh: + s.Qml.NotifyAddressChanged(address) + case address := <-addressChangedLogoutCh: + s.Qml.NotifyAddressChangedLogout(address) + case userID := <-logoutCh: + user, err := s.bridge.GetUser(userID) + if err != nil { + return + } + s.Qml.NotifyLogout(user.Username()) + case <-updateApplicationCh: + s.Qml.ProcessFinished() + s.Qml.NotifyUpdate() + case <-newUserCh: + s.Qml.LoadAccounts() + case <-certIssue: + s.Qml.ShowCertIssue() + } + } +} + +func (s *FrontendQt) getEventChannel(event string) <-chan string { + ch := make(chan string) + s.eventListener.Add(event, ch) + return ch +} + +// Loop function for tests. +// +// It runs QtExecute in new thread with function returning itself after setup. +// Therefore it is possible to run tests on background. +func (s *FrontendQt) Start() (err error) { + uiready := make(chan *FrontendQt) + go func() { + err := s.qtExecute(func(self *FrontendQt) error { + // NOTE: Trick to send back UI by channel to access functionality + // inside application thread. Other only uninitialized `ui` is visible. + uiready <- self + return nil + }) + if err != nil { + log.Error(err) + } + uiready <- nil + }() + + // Receive UI pointer and set all pointers. + running := <-uiready + s.App = running.App + s.View = running.View + s.MainWin = running.MainWin + return nil +} + +func (s *FrontendQt) IsAppRestarting() bool { + return s.Qml.IsRestarting() +} + +// InvMethod runs the function with name `method` defined in RootObject of the QML. +// Used for tests. +func (s *FrontendQt) InvMethod(method string) error { + arg := core.NewQGenericArgument("", nil) + PauseLong() + isGoodMethod := core.QMetaObject_InvokeMethod4(s.MainWin, method, arg, arg, arg, arg, arg, arg, arg, arg, arg, arg) + if isGoodMethod == false { + return errors.New("Wrong method " + method) + } + return nil +} + +// QtSetupCoreAndControls hanldes global setup of Qt. +// Should be called once per program. Probably once per thread is fine. +func (s *FrontendQt) QtSetupCoreAndControls() { + installMessageHandler() + // Core setup. + core.QCoreApplication_SetApplicationName(s.programName) + core.QCoreApplication_SetApplicationVersion(s.programVer) + // High DPI scaling for windows. + core.QCoreApplication_SetAttribute(core.Qt__AA_EnableHighDpiScaling, false) + // Software OpenGL: to avoid dedicated GPU. + core.QCoreApplication_SetAttribute(core.Qt__AA_UseSoftwareOpenGL, true) + // Basic style for QuickControls2 objects. + //quickcontrols2.QQuickStyle_SetStyle("material") +} + +// qtExecute is the main function for starting the Qt application. +// +// It is better to have just one Qt application per program (at least per same +// thread). This functions reads the main user interface defined in QML files. +// The files are appended to library by Qt-QRC. +func (s *FrontendQt) qtExecute(Procedure func(*FrontendQt) error) error { + s.QtSetupCoreAndControls() + s.App = widgets.NewQApplication(len(os.Args), os.Args) + if runtime.GOOS == "linux" { // Fix default font. + s.App.SetFont(gui.NewQFont2(FcMatchSans(), 12, int(gui.QFont__Normal), false), "") + } + s.App.SetQuitOnLastWindowClosed(false) // Just to make sure it's not closed. + + s.View = qml.NewQQmlApplicationEngine(s.App) + // Add Go-QML bridge. + s.Qml = NewGoQMLInterface(nil) + s.Qml.SetIsShownOnStart(s.showWindowOnStart) + s.Qml.SetFrontend(s) // provides access + s.View.RootContext().SetContextProperty("go", s.Qml) + + // Set first start flag. + s.Qml.SetIsFirstStart(s.preferences.GetBool(preferences.FirstStartKey)) + // Don't repeat next start. + s.preferences.SetBool(preferences.FirstStartKey, false) + + // Check if it is first start after update (fresh version). + lastVersion := s.preferences.Get(preferences.LastVersionKey) + s.Qml.SetIsFreshVersion(lastVersion != "" && s.version != lastVersion) + s.preferences.Set(preferences.LastVersionKey, s.version) + + // Add AccountsModel. + s.Accounts = NewAccountsModel(nil) + s.View.RootContext().SetContextProperty("accountsModel", s.Accounts) + // Import path and load QML files. + s.View.AddImportPath("qrc:///") + s.View.Load(core.NewQUrl3("qrc:/ui.qml", 0)) + + // List of used packages. + s.Qml.SetCredits(bridge.Credits) + s.Qml.SetFullversion(s.buildVersion) + + // Autostart. + if s.Qml.IsFirstStart() { + if s.AutostartEntry.IsEnabled() { + if err := s.AutostartEntry.Disable(); err != nil { + log.Error("First disable ", err) + s.autostartError(err) + } + } + s.toggleAutoStart() + } + if s.AutostartEntry.IsEnabled() { + s.Qml.SetIsAutoStart(true) + } else { + s.Qml.SetIsAutoStart(false) + } + + if s.preferences.GetBool(preferences.AllowProxyKey) { + s.Qml.SetIsProxyAllowed(true) + } else { + s.Qml.SetIsProxyAllowed(false) + } + + // Notify user about error during initialization. + if s.notifyHasNoKeychain { + s.Qml.NotifyHasNoKeychain() + } + + s.eventListener.RetryEmit(events.TLSCertIssue) + s.eventListener.RetryEmit(events.ErrorEvent) + + // Set reporting of outgoing email without encryption. + s.Qml.SetIsReportingOutgoingNoEnc(s.preferences.GetBool(preferences.ReportOutgoingNoEncKey)) + + // IMAP/SMTP ports. + s.Qml.SetIsDefaultPort( + s.config.GetDefaultIMAPPort() == s.preferences.GetInt(preferences.IMAPPortKey) && + s.config.GetDefaultSMTPPort() == s.preferences.GetInt(preferences.SMTPPortKey), + ) + + // Check QML is loaded properly. + if len(s.View.RootObjects()) == 0 { + return errors.New("QML not loaded properly") + } + + // Obtain main window (need for invoke method). + s.MainWin = s.View.RootObjects()[0] + SetupSystray(s) + + // Injected procedure for out-of-main-thread applications. + if err := Procedure(s); err != nil { + return err + } + + // Loop + if ret := gui.QGuiApplication_Exec(); ret != 0 { + err := errors.New("Event loop ended with return value:" + string(ret)) + log.Warn("QGuiApplication_Exec: ", err) + return err + } + HideSystray() + return nil +} + +func (s *FrontendQt) openLogs() { + go open.Run(s.config.GetLogDir()) +} + +// Check version in separate goroutine to not block the GUI (avoid program not responding message). +func (s *FrontendQt) isNewVersionAvailable(showMessage bool) { + go func() { + defer s.panicHandler.HandlePanic() + defer s.Qml.ProcessFinished() + isUpToDate, latestVersionInfo, err := s.updates.CheckIsBridgeUpToDate() + if err != nil { + log.Warn("Can not retrieve version info: ", err) + s.checkInternet() + return + } + s.Qml.SetConnectionStatus(true) // If we are here connection is ok. + if isUpToDate { + s.Qml.SetUpdateState("upToDate") + if showMessage { + s.Qml.NotifyVersionIsTheLatest() + } + return + } + s.Qml.SetNewversion(latestVersionInfo.Version) + s.Qml.SetChangelog(latestVersionInfo.ReleaseNotes) + s.Qml.SetBugfixes(latestVersionInfo.ReleaseFixedBugs) + s.Qml.SetLandingPage(latestVersionInfo.LandingPage) + s.Qml.SetDownloadLink(latestVersionInfo.GetDownloadLink()) + s.Qml.ShowWindow() + s.Qml.SetUpdateState("oldVersion") + }() +} + +func (s *FrontendQt) getLocalVersionInfo() { + defer s.Qml.ProcessFinished() + localVersion := s.updates.GetLocalVersion() + s.Qml.SetNewversion(localVersion.Version) + s.Qml.SetChangelog(localVersion.ReleaseNotes) + s.Qml.SetBugfixes(localVersion.ReleaseFixedBugs) +} + +func (s *FrontendQt) sendBug(description, client, address string) (isOK bool) { + isOK = true + var accname = "No account logged in" + if s.Accounts.Count() > 0 { + accname = s.Accounts.get(0).Account() + } + if err := s.bridge.ReportBug( + core.QSysInfo_ProductType(), + core.QSysInfo_PrettyProductName(), + description, + accname, + address, + client, + ); err != nil { + log.Error("while sendBug: ", err) + isOK = false + } + return +} + +func (s *FrontendQt) getLastMailClient() string { + return s.bridge.GetCurrentClient() +} + +func (s *FrontendQt) configureAppleMail(iAccount, iAddress int) { + acc := s.Accounts.get(iAccount) + + user, err := s.bridge.GetUser(acc.UserID()) + if err != nil { + log.Warn("UserConfigFromKeychain failed: ", acc.Account(), err) + s.SendNotification(TabAccount, s.Qml.GenericErrSeeLogs()) + return + } + + imapPort := s.preferences.GetInt(preferences.IMAPPortKey) + imapSSL := false + smtpPort := s.preferences.GetInt(preferences.SMTPPortKey) + smtpSSL := s.preferences.GetBool(preferences.SMTPSSLKey) + + // If configuring apple mail for Catalina or newer, users should use SSL. + doRestart := false + if !smtpSSL && useragent.IsCatalinaOrNewer() { + smtpSSL = true + s.preferences.SetBool(preferences.SMTPSSLKey, true) + log.Warn("Detected Catalina or newer with bad SMTP SSL settings, now using SSL, bridge needs to restart") + doRestart = true + } + + for _, autoConf := range autoconfig.Available() { + if err := autoConf.Configure(imapPort, smtpPort, imapSSL, smtpSSL, user, iAddress); err != nil { + log.Warn("Autoconfig failed: ", autoConf.Name(), err) + s.SendNotification(TabAccount, s.Qml.GenericErrSeeLogs()) + return + } + } + + if doRestart { + time.Sleep(2 * time.Second) + s.Qml.SetIsRestarting(true) + s.App.Quit() + } + return +} + +func (s *FrontendQt) toggleAutoStart() { + defer s.Qml.ProcessFinished() + var err error + if s.AutostartEntry.IsEnabled() { + err = s.AutostartEntry.Disable() + } else { + err = s.AutostartEntry.Enable() + } + if err != nil { + log.Error("Enable autostart: ", err) + s.autostartError(err) + } + if s.AutostartEntry.IsEnabled() { + s.Qml.SetIsAutoStart(true) + } else { + s.Qml.SetIsAutoStart(false) + } +} + +func (s *FrontendQt) toggleAllowProxy() { + defer s.Qml.ProcessFinished() + + if s.preferences.GetBool(preferences.AllowProxyKey) { + s.preferences.SetBool(preferences.AllowProxyKey, false) + bridge.DisallowDoH() + s.Qml.SetIsProxyAllowed(false) + } else { + s.preferences.SetBool(preferences.AllowProxyKey, true) + bridge.AllowDoH() + s.Qml.SetIsProxyAllowed(true) + } +} + +func (s *FrontendQt) getIMAPPort() string { + return s.preferences.Get(preferences.IMAPPortKey) +} + +func (s *FrontendQt) getSMTPPort() string { + return s.preferences.Get(preferences.SMTPPortKey) +} + +// Return 0 -- port is free to use for server. +// Return 1 -- port is occupied. +func (s *FrontendQt) isPortOpen(portStr string) int { + portInt, err := strconv.Atoi(portStr) + if err != nil { + return 1 + } + if !ports.IsPortFree(portInt) { + return 1 + } + return 0 +} + +func (s *FrontendQt) setPortsAndSecurity(imapPort, smtpPort string, useSTARTTLSforSMTP bool) { + s.preferences.Set(preferences.IMAPPortKey, imapPort) + s.preferences.Set(preferences.SMTPPortKey, smtpPort) + s.preferences.SetBool(preferences.SMTPSSLKey, !useSTARTTLSforSMTP) +} + +func (s *FrontendQt) isSMTPSTARTTLS() bool { + return !s.preferences.GetBool(preferences.SMTPSSLKey) +} + +func (s *FrontendQt) checkInternet() { + s.Qml.SetConnectionStatus(IsInternetAvailable()) +} + +func (s *FrontendQt) switchAddressModeUser(iAccount int) { + defer s.Qml.ProcessFinished() + userID := s.Accounts.get(iAccount).UserID() + user, err := s.bridge.GetUser(userID) + if err != nil { + log.Error("Get user for switch address mode failed: ", err) + s.SendNotification(TabAccount, s.Qml.GenericErrSeeLogs()) + return + } + if err := user.SwitchAddressMode(); err != nil { + log.Error("Switch address mode failed: ", err) + s.SendNotification(TabAccount, s.Qml.GenericErrSeeLogs()) + return + } + s.userIDAdded = userID +} + +func (s *FrontendQt) autostartError(err error) { + if strings.Contains(err.Error(), "permission denied") { + s.Qml.FailedAutostartCode("permission") + } else if strings.Contains(err.Error(), "error code: 0x") { + errorCode := err.Error() + errorCode = errorCode[len(errorCode)-8:] + s.Qml.FailedAutostartCode(errorCode) + } else { + s.Qml.FailedAutostartCode("") + } +} + +func (s *FrontendQt) toggleIsReportingOutgoingNoEnc() { + shouldReport := !s.Qml.IsReportingOutgoingNoEnc() + s.preferences.SetBool(preferences.ReportOutgoingNoEncKey, shouldReport) + s.Qml.SetIsReportingOutgoingNoEnc(shouldReport) +} + +func (s *FrontendQt) shouldSendAnswer(messageID string, shouldSend bool) { + s.noEncConfirmator.ConfirmNoEncryption(messageID, shouldSend) +} + +func (s *FrontendQt) saveOutgoingNoEncPopupCoord(x, y float32) { + //prefs.SetFloat(prefs.OutgoingNoEncPopupCoordX, x) + //prefs.SetFloat(prefs.OutgoingNoEncPopupCoordY, y) +} + +func (s *FrontendQt) StartUpdate() { + progress := make(chan updates.Progress) + go func() { // Update progress in QML. + defer s.panicHandler.HandlePanic() + for current := range progress { + s.Qml.SetProgress(current.Processed) + s.Qml.SetProgressDescription(current.Description) + // Error happend + if current.Err != nil { + log.Error("update progress: ", current.Err) + s.Qml.UpdateFinished(true) + return + } + // Finished everything OK. + if current.Description >= updates.InfoQuitApp { + s.Qml.UpdateFinished(false) + time.Sleep(3 * time.Second) // Just notify. + s.Qml.SetIsRestarting(current.Description == updates.InfoRestartApp) + s.App.Quit() + return + } + } + }() + go func() { + defer s.panicHandler.HandlePanic() + s.updates.StartUpgrade(progress) + }() +} diff --git a/internal/frontend/qt/frontend_nogui.go b/internal/frontend/qt/frontend_nogui.go new file mode 100644 index 00000000..89e5dbc5 --- /dev/null +++ b/internal/frontend/qt/frontend_nogui.go @@ -0,0 +1,59 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build nogui + +package qt + +import ( + "fmt" + "net/http" + + "github.com/ProtonMail/proton-bridge/internal/frontend/types" + "github.com/ProtonMail/proton-bridge/pkg/config" + "github.com/ProtonMail/proton-bridge/pkg/listener" +) + +var log = config.GetLogEntry("frontend-nogui") //nolint[gochecknoglobals] + +type FrontendHeadless struct{} + +func (s *FrontendHeadless) Loop(credentialsError error) error { + log.Info("Check status on localhost:8081") + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "Bridge is running") + }) + return http.ListenAndServe(":8081", nil) +} + +func (s *FrontendHeadless) InstanceExistAlert() {} +func (s *FrontendHeadless) IsAppRestarting() bool { return false } + +func New( + version, + buildVersion string, + showWindowOnStart bool, + panicHandler types.PanicHandler, + config *config.Config, + preferences *config.Preferences, + eventListener listener.Listener, + updates types.Updater, + bridge types.Bridger, + noEncConfirmator types.NoEncConfirmator, +) *FrontendHeadless { + return &FrontendHeadless{} +} diff --git a/internal/frontend/qt/helpers.go b/internal/frontend/qt/helpers.go new file mode 100644 index 00000000..89be86e6 --- /dev/null +++ b/internal/frontend/qt/helpers.go @@ -0,0 +1,84 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build !nogui + +package qt + +import ( + "bufio" + "os" + "os/exec" + "time" + + "github.com/ProtonMail/proton-bridge/pkg/connection" + "github.com/therecipe/qt/core" +) + +// NewQByteArrayFromString is a wrapper for new QByteArray from string. +func NewQByteArrayFromString(name string) *core.QByteArray { + return core.NewQByteArray2(name, len(name)) +} + +// NewQVariantString is a wrapper for QVariant alocator String. +func NewQVariantString(data string) *core.QVariant { + return core.NewQVariant1(data) +} + +// NewQVariantStringArray is a wrapper for QVariant alocator String Array. +func NewQVariantStringArray(data []string) *core.QVariant { + return core.NewQVariant1(data) +} + +// NewQVariantBool is a wrapper for QVariant alocator Bool. +func NewQVariantBool(data bool) *core.QVariant { + return core.NewQVariant1(data) +} + +// NewQVariantInt is a wrapper for QVariant alocator Int. +func NewQVariantInt(data int) *core.QVariant { + return core.NewQVariant1(data) +} + +// Pause is used to show GUI tests. +func Pause() { + time.Sleep(500 * time.Millisecond) +} + +// PauseLong is used to diplay GUI tests. +func PauseLong() { + time.Sleep(3 * time.Second) +} + +func IsInternetAvailable() bool { + return connection.CheckInternetConnection() == nil +} + +// FIXME: Not working in test... +func WaitForEnter() { + log.Print("Press 'Enter' to continue...") + bufio.NewReader(os.Stdin).ReadBytes('\n') +} + +func FcMatchSans() (family string) { + family = "DejaVu Sans" + fcMatch, err := exec.Command("fc-match", "-f", "'%{family}'", "sans-serif").Output() + if err == nil { + return string(fcMatch) + } + return +} diff --git a/internal/frontend/qt/logs.cpp b/internal/frontend/qt/logs.cpp new file mode 100644 index 00000000..e174e525 --- /dev/null +++ b/internal/frontend/qt/logs.cpp @@ -0,0 +1,23 @@ +// +build !nogui + + +#include "logs.h" +#include "_cgo_export.h" + +#include +#include +#include + +void messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &msg) +{ + Q_UNUSED(type); + Q_UNUSED(context); + + QByteArray localMsg = msg.toUtf8().prepend("WHITESPACE"); + logMsgPacked( + const_cast( (localMsg.constData()) +10 ), + localMsg.size()-10 + ); + //printf("Handler: %s (%s:%u, %s)\n", localMsg.constData(), context.file, context.line, context.function); +} +void InstallMessageHandler() { qInstallMessageHandler(messageHandler); } diff --git a/internal/frontend/qt/logs.go b/internal/frontend/qt/logs.go new file mode 100644 index 00000000..9b75950c --- /dev/null +++ b/internal/frontend/qt/logs.go @@ -0,0 +1,38 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build !nogui + +package qt + +//#include "logs.h" +import "C" + +import ( + "github.com/sirupsen/logrus" +) + +func installMessageHandler() { + C.InstallMessageHandler() +} + +//export logMsgPacked +func logMsgPacked(data *C.char, len C.int) { + log.WithFields(logrus.Fields{ + "pkg": "frontend-qml", + }).Warnln(C.GoStringN(data, len)) +} diff --git a/internal/frontend/qt/logs.h b/internal/frontend/qt/logs.h new file mode 100644 index 00000000..48b392f5 --- /dev/null +++ b/internal/frontend/qt/logs.h @@ -0,0 +1,20 @@ + +#pragma once + +#ifndef GO_LOG_H +#define GO_LOG_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif // C++ + +void InstallMessageHandler(); +; + +#ifdef __cplusplus +} +#endif // C++ + +#endif // LOG diff --git a/internal/frontend/qt/notification.go b/internal/frontend/qt/notification.go new file mode 100644 index 00000000..961ef7c8 --- /dev/null +++ b/internal/frontend/qt/notification.go @@ -0,0 +1,33 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build !nogui + +package qt + +const ( + TabAccount = 0 + TabSettings = 1 + TabHelp = 2 + TabQuit = 4 + TabUpdates = 100 + TabAddAccount = -1 +) + +func (s *FrontendQt) SendNotification(tabIndex int, msg string) { + s.Qml.NotifyBubble(tabIndex, msg) +} diff --git a/internal/frontend/qt/resources.qrc b/internal/frontend/qt/resources.qrc new file mode 100644 index 00000000..a7c05d71 --- /dev/null +++ b/internal/frontend/qt/resources.qrc @@ -0,0 +1,77 @@ + + + + + ../qml/ProtonUI/qmldir + ../qml/ProtonUI/AccessibleButton.qml + ../qml/ProtonUI/AccessibleText.qml + ../qml/ProtonUI/AccessibleSelectableText.qml + ../qml/ProtonUI/AccountView.qml + ../qml/ProtonUI/AddAccountBar.qml + ../qml/ProtonUI/BubbleNote.qml + ../qml/ProtonUI/BugReportWindow.qml + ../qml/ProtonUI/ButtonIconText.qml + ../qml/ProtonUI/ButtonRounded.qml + ../qml/ProtonUI/CheckBoxLabel.qml + ../qml/ProtonUI/ClickIconText.qml + ../qml/ProtonUI/Dialog.qml + ../qml/ProtonUI/DialogAddUser.qml + ../qml/ProtonUI/DialogUpdate.qml + ../qml/ProtonUI/DialogConnectionTroubleshoot.qml + ../qml/ProtonUI/FileAndFolderSelect.qml + ../qml/ProtonUI/InformationBar.qml + ../qml/ProtonUI/InputField.qml + ../qml/ProtonUI/InstanceExistsWindow.qml + ../qml/ProtonUI/LogoHeader.qml + ../qml/ProtonUI/PopupMessage.qml + ../qml/ProtonUI/Style.qml + ../qml/ProtonUI/TabButton.qml + ../qml/ProtonUI/TabLabels.qml + ../qml/ProtonUI/TextLabel.qml + ../qml/ProtonUI/TextValue.qml + ../qml/ProtonUI/TLSCertPinIssueBar.qml + ../qml/ProtonUI/WindowTitleBar.qml + ../share/fontawesome-webfont.ttf + + + ../share/icons/rounded-systray.png + ../share/icons/rounded-syswarn.png + ../share/icons/rounded-syswarn.png + ../share/icons/white-systray.png + ../share/icons/white-syswarn.png + ../share/icons/white-syserror.png + ../share/icons/rounded-app.png + ../share/icons/pm_logo.png + ../share/icons/win10_Dash.png + ../share/icons/win10_Times.png + ../share/icons/macos_gray.png + ../share/icons/macos_red.png + ../share/icons/macos_red_hl.png + ../share/icons/macos_red_dark.png + ../share/icons/macos_yellow.png + ../share/icons/macos_yellow_hl.png + ../share/icons/macos_yellow_dark.png + + + ../qml/BridgeUI/qmldir + ../qml/BridgeUI/AccountDelegate.qml + ../qml/BridgeUI/BubbleMenu.qml + ../qml/BridgeUI/Credits.qml + ../qml/BridgeUI/DialogFirstStart.qml + ../qml/BridgeUI/DialogPortChange.qml + ../qml/BridgeUI/DialogYesNo.qml + ../qml/BridgeUI/DialogTLSCertInfo.qml + ../qml/BridgeUI/HelpView.qml + ../qml/BridgeUI/InfoWindow.qml + ../qml/BridgeUI/MainWindow.qml + ../qml/BridgeUI/ManualWindow.qml + ../qml/BridgeUI/OutgoingNoEncPopup.qml + ../qml/BridgeUI/SettingsView.qml + ../qml/BridgeUI/StatusFooter.qml + ../qml/BridgeUI/VersionInfo.qml + + + ../qml/Gui.qml + + + diff --git a/internal/frontend/qt/systray.go b/internal/frontend/qt/systray.go new file mode 100644 index 00000000..c1bcf171 --- /dev/null +++ b/internal/frontend/qt/systray.go @@ -0,0 +1,117 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build !nogui + +package qt + +import ( + "runtime" + + "github.com/therecipe/qt/core" + "github.com/therecipe/qt/gui" + "github.com/therecipe/qt/widgets" +) + +const ( + systrayNormal = "" + systrayWarning = "-warn" + systrayError = "-error" +) + +func min(a, b int) int { + if b < a { + return b + } + return a +} + +func max(a, b int) int { + if b > a { + return b + } + return a +} + +var systray *widgets.QSystemTrayIcon + +func SetupSystray(frontend *FrontendQt) { + systray = widgets.NewQSystemTrayIcon(nil) + NormalSystray() + systray.SetToolTip(frontend.programName) + systray.SetContextMenu(createMenu(frontend, systray)) + + if runtime.GOOS != "darwin" { + systray.ConnectActivated(func(reason widgets.QSystemTrayIcon__ActivationReason) { + switch reason { + case widgets.QSystemTrayIcon__Trigger, widgets.QSystemTrayIcon__DoubleClick: + frontend.Qml.ShowWindow() + default: + systray.ContextMenu().Exec2(menuPosition(systray), nil) + } + }) + } + + systray.Show() +} + +func qsTr(msg string) string { + return systray.Tr(msg, "Systray menu", -1) +} + +func createMenu(frontend *FrontendQt, systray *widgets.QSystemTrayIcon) *widgets.QMenu { + menu := widgets.NewQMenu(nil) + menu.AddAction(qsTr("Open")).ConnectTriggered(func(ok bool) { frontend.Qml.ShowWindow() }) + menu.AddAction(qsTr("Help")).ConnectTriggered(func(ok bool) { frontend.Qml.ShowHelp() }) + menu.AddAction(qsTr("Quit")).ConnectTriggered(func(ok bool) { frontend.Qml.ShowQuit() }) + return menu +} + +func menuPosition(systray *widgets.QSystemTrayIcon) *core.QPoint { + var availRec = gui.QGuiApplication_PrimaryScreen().AvailableGeometry() + var trayRec = systray.Geometry() + var x = max(availRec.Left(), min(trayRec.X(), availRec.Right()-trayRec.Width())) + var y = max(availRec.Top(), min(trayRec.Y(), availRec.Bottom()-trayRec.Height())) + return core.NewQPoint2(x, y) +} + +func showSystray(systrayType string) { + path := ":/ProtonUI/images/systray" + systrayType + if runtime.GOOS == "darwin" { + path += "-mono" + } + path += ".png" + icon := gui.NewQIcon5(path) + icon.SetIsMask(true) + systray.SetIcon(icon) +} + +func NormalSystray() { + showSystray(systrayNormal) +} + +func HighlightSystray() { + showSystray(systrayWarning) +} + +func ErrorSystray() { + showSystray(systrayError) +} + +func HideSystray() { + systray.Hide() +} diff --git a/internal/frontend/qt/translate.ts b/internal/frontend/qt/translate.ts new file mode 100644 index 00000000..acae0f76 --- /dev/null +++ b/internal/frontend/qt/translate.ts @@ -0,0 +1,669 @@ + + + + + AccountDelegate + + + Logout + + + + + Remove + + + + + connected + + + + + Log out + + + + + + disconnected + + + + + + Log in + + + + + AccountView + + + No accounts added + + + + + ACCOUNT + + + + + STATUS + + + + + ACTIONS + + + + + AddAccountBar + + + Add Account + + + + + Help + + + + + BubbleMenu + + + About + + + + + BugReportWindow + + + Please write a brief description of the bug(s) you have encountered... + + + + + Email client: + + + + + Contact email: + + + + + Bug reports are not end-to-end encrypted! + + + + + Please do not send any sensitive information. + + + + + Contact us at security@protonmail.com for critical security issues. + + + + + Cancel + + + + + Send + + + + + Field required + + + + + DialogAddUser + + + Log in to your ProtonMail account + + + + + Username: + + + + + Cancel + + + + + + + Next + + + + + Sign Up for an Account + + + + + Password for %1 + + + + + Mailbox password for %1 + + + + + Two Factor Code + + + + + + Back + + + + + Logging in + + + + + Adding account, please wait ... + + + + + Required field + + + + + DialogPortChange + + + IMAP port + + + + + SMTP port + + + + + Cancel + + + + + Okay + + + + + Settings will be applied after next start. You may need to re-configure your email client. + + + + + Bridge will now restart. + + + + + DialogYesNo + + + Additionally delete all stored preferences and data + + + + + No + + + + + Yes + + + + + Waiting... + + + + + Close Bridge + + + + + Are you sure you want to close the Bridge? + + + + + Closing Bridge... + + + + + Logout + + + + + Logging out... + + + + + Delete account + + + + + Are you sure you want to remove this account? + + + + + Deleting ... + + + + + Clear keychain + + + + + Are you sure you want to clear your keychain? + + + + + Clearing the keychain ... + + + + + Clear cache + + + + + Are you sure you want to clear your local cache? + + + + + Clearing the cache ... + + + + + Checking for updates ... + + + + + Turning on automatic start of Bridge... + + + + + Turning off automatic start of Bridge... + + + + + You have the latest version! + + + + + Gui + + + Account %1 has been disconnected. Please log in to continue to use the Bridge with this account. + + + + + Incorrect username or password. + notification + + + + + + + Incorrect mailbox password. + notification + + + + + + + Cannot contact server, please check your internet connection. + notification + + + + + + + Credentials could not be removed. + notification + + + + + + + Unable to submit bug report. + notification + + + + + + + Bug report successfully sent. + notification + + + + + + + HelpView + + + Logs + + + + + Report Bug + + + + + Setup Guide + + + + + Check for Updates + + + + + Credits + + + + + HelperPane + + + Skip + + + + + Back + + + + + Next + + + + + You can add, delete, logout account. Expand account to see settings. + + + + + This is settings windows: Clear cache, list logs, ... + + + + + Welcome to ProtonMail Bridge! Add account to start. + + + + + InfoWindow + + + IMAP SETTINGS + + + + + + Hostname: + + + + + + Port: + + + + + + Username: + + + + + + Password: + + + + + SMTP SETTINGS + + + + + Configure Apple Mail + + + + + InformationBar + + + An update is available. + + + + + No Internet connection + + + + + Install + + + + + Retry + + + + + Dismiss + + + + + InstanceExistsWindow + + + ProtonMail Bridge + + + + + Warning: Instance exists + + + + + An instance of the ProtonMail Bridge is already running. + + + + + Please close the existing ProtonMail Bridge process before starting a new one. + + + + + This program will close now. + + + + + Okay + + + + + MainWindow + + + ProtonMail Bridge + + + + + Accounts + + + + + Settings + + + + + Help + + + + + Click here to start + + + + + Credits + + + + + Information about version + + + + + SettingsView + + + Clear Cache + + + + + + Clear + + + + + Clear Keychain + + + + + Automatically Start Bridge + + + + + Advanced settings + + + + + Change SMTP/IMAP Ports + + + + + Change + + + + + StatusFooter + + + Quit + + + + + TabLabels + + + Close Bridge + + + + + VersionInfo + + + Release notes: + + + + + Fixed bugs: + + + + diff --git a/internal/frontend/qt/ui.go b/internal/frontend/qt/ui.go new file mode 100644 index 00000000..124c1e9a --- /dev/null +++ b/internal/frontend/qt/ui.go @@ -0,0 +1,192 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build !nogui + +package qt + +import ( + "runtime" + + "github.com/therecipe/qt/core" +) + +// Interface between go and qml. +// +// Here we implement all the signals / methods. +type GoQMLInterface struct { + core.QObject + + _ func() `constructor:"init"` + + _ bool `property:"isAutoStart"` + _ bool `property:"isProxyAllowed"` + _ string `property:"currentAddress"` + _ string `property:"goos"` + _ string `property:"credits"` + _ bool `property:"isShownOnStart"` + _ bool `property:"isFirstStart"` + _ bool `property:"isFreshVersion"` + _ bool `property:"isRestarting"` + _ bool `property:"isConnectionOK"` + _ bool `property:"isDefaultPort"` + + _ string `property:"programTitle"` + _ string `property:"newversion"` + _ string `property:"fullversion"` + _ string `property:"downloadLink"` + _ string `property:"landingPage"` + _ string `property:"changelog"` + _ string `property:"bugfixes"` + + // Translations. + _ string `property:"wrongCredentials"` + _ string `property:"wrongMailboxPassword"` + _ string `property:"canNotReachAPI"` + _ string `property:"credentialsNotRemoved"` + _ string `property:"versionCheckFailed"` + _ string `property:"failedAutostartPerm"` + _ string `property:"failedAutostart"` + _ string `property:"genericErrSeeLogs"` + + _ float32 `property:"progress"` + _ int `property:"progressDescription"` + + _ func(isAvailable bool) `signal:"setConnectionStatus"` + _ func(updateState string) `signal:"setUpdateState"` + _ func() `slot:"checkInternet"` + + _ func(systX, systY, systW, systH int) `signal:"toggleMainWin"` + + _ func() `signal:"processFinished"` + _ func() `signal:"openManual"` + _ func(showMessage bool) `signal:"runCheckVersion"` + _ func() `signal:"toggleMainWin"` + + _ func() `signal:"showWindow"` + _ func() `signal:"showHelp"` + _ func() `signal:"showQuit"` + + _ func() `slot:"toggleAutoStart"` + _ func() `slot:"toggleAllowProxy"` + _ func() `slot:"loadAccounts"` + _ func() `slot:"openLogs"` + _ func() `slot:"clearCache"` + _ func() `slot:"clearKeychain"` + _ func() `slot:"highlightSystray"` + _ func() `slot:"errorSystray"` + _ func() `slot:"normalSystray"` + + _ func() `slot:"getLocalVersionInfo"` + _ func(showMessage bool) `slot:"isNewVersionAvailable"` + _ func() string `slot:"getBackendVersion"` + _ func() string `slot:"getIMAPPort"` + _ func() string `slot:"getSMTPPort"` + _ func() string `slot:"getLastMailClient"` + _ func(portStr string) int `slot:"isPortOpen"` + _ func(imapPort, smtpPort string, useSTARTTLSforSMTP bool) `slot:"setPortsAndSecurity"` + _ func() bool `slot:"isSMTPSTARTTLS"` + + _ func(description, client, address string) bool `slot:"sendBug"` + + _ func(tabIndex int, message string) `signal:"notifyBubble"` + _ func(tabIndex int, message string) `signal:"silentBubble"` + _ func() `signal:"bubbleClosed"` + + _ func(iAccount int, removePreferences bool) `slot:"deleteAccount"` + _ func(iAccount int) `slot:"logoutAccount"` + _ func(iAccount int, iAddress int) `slot:"configureAppleMail"` + _ func(iAccount int) `signal:"switchAddressMode"` + + _ func(login, password string) int `slot:"login"` + _ func(twoFacAuth string) int `slot:"auth2FA"` + _ func(mailboxPassword string) int `slot:"addAccount"` + _ func(message string, changeIndex int) `signal:"setAddAccountWarning"` + + _ func() `signal:"notifyVersionIsTheLatest"` + _ func() `signal:"notifyKeychainRebuild"` + _ func() `signal:"notifyHasNoKeychain"` + _ func() `signal:"notifyUpdate"` + _ func(accname string) `signal:"notifyLogout"` + _ func(accname string) `signal:"notifyAddressChanged"` + _ func(accname string) `signal:"notifyAddressChangedLogout"` + _ func(busyPortIMAP, busyPortSMTP bool) `signal:"notifyPortIssue"` + _ func(code string) `signal:"failedAutostartCode"` + + _ bool `property:"isReportingOutgoingNoEnc"` + _ func() `slot:"toggleIsReportingOutgoingNoEnc"` + _ func(messageID string, shouldSend bool) `slot:"shouldSendAnswer"` + _ func(messageID, subject string) `signal:"showOutgoingNoEncPopup"` + _ func(x, y float32) `signal:"setOutgoingNoEncPopupCoord"` + _ func(x, y float32) `slot:"saveOutgoingNoEncPopupCoord"` + _ func(recipient string) `signal:"showNoActiveKeyForRecipient"` + _ func() `signal:"showCertIssue"` + + _ func() `slot:"startUpdate"` + _ func(hasError bool) `signal:"updateFinished"` +} + +// init is basically the constructor. +func (s *GoQMLInterface) init() {} + +// SetFrontend connects all slots and signals from Go to QML. +func (s *GoQMLInterface) SetFrontend(f *FrontendQt) { + s.ConnectToggleAutoStart(f.toggleAutoStart) + s.ConnectToggleAllowProxy(f.toggleAllowProxy) + s.ConnectLoadAccounts(f.loadAccounts) + s.ConnectOpenLogs(f.openLogs) + s.ConnectClearCache(f.clearCache) + s.ConnectClearKeychain(f.clearKeychain) + + s.ConnectGetLocalVersionInfo(f.getLocalVersionInfo) + s.ConnectIsNewVersionAvailable(f.isNewVersionAvailable) + s.ConnectGetIMAPPort(f.getIMAPPort) + s.ConnectGetSMTPPort(f.getSMTPPort) + s.ConnectGetLastMailClient(f.getLastMailClient) + s.ConnectIsPortOpen(f.isPortOpen) + s.ConnectIsSMTPSTARTTLS(f.isSMTPSTARTTLS) + + s.ConnectSendBug(f.sendBug) + + s.ConnectDeleteAccount(f.deleteAccount) + s.ConnectLogoutAccount(f.logoutAccount) + s.ConnectConfigureAppleMail(f.configureAppleMail) + s.ConnectLogin(f.login) + s.ConnectAuth2FA(f.auth2FA) + s.ConnectAddAccount(f.addAccount) + s.ConnectSetPortsAndSecurity(f.setPortsAndSecurity) + + s.ConnectHighlightSystray(HighlightSystray) + s.ConnectErrorSystray(ErrorSystray) + s.ConnectNormalSystray(NormalSystray) + s.ConnectSwitchAddressMode(f.switchAddressModeUser) + + s.SetGoos(runtime.GOOS) + s.SetIsRestarting(false) + s.SetProgramTitle(f.programName) + + s.ConnectGetBackendVersion(func() string { + return f.programVer + }) + + s.ConnectCheckInternet(f.checkInternet) + + s.ConnectToggleIsReportingOutgoingNoEnc(f.toggleIsReportingOutgoingNoEnc) + s.ConnectShouldSendAnswer(f.shouldSendAnswer) + s.ConnectSaveOutgoingNoEncPopupCoord(f.saveOutgoingNoEncPopupCoord) + s.ConnectStartUpdate(f.StartUpdate) +} diff --git a/internal/frontend/share/fontawesome-webfont.ttf b/internal/frontend/share/fontawesome-webfont.ttf new file mode 100644 index 0000000000000000000000000000000000000000..35acda2fa1196aad98c2adf4378a7611dd713aa3 GIT binary patch literal 165548 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 z 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 d0mjX3f3XexQ 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

  • #q=( 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 + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/frontend/share/icons/black-syserror.png b/internal/frontend/share/icons/black-syserror.png new file mode 100644 index 0000000000000000000000000000000000000000..d1366d0e9700fc906e9eae9b1d9a8bdfbf8185c7 GIT binary patch literal 34901 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 BLXQ9d literal 0 HcmV?d00001 diff --git a/internal/frontend/share/icons/black-syswarn.png b/internal/frontend/share/icons/black-syswarn.png new file mode 100644 index 0000000000000000000000000000000000000000..86f1aa27de0f5ab035bbab981f6fb34ee01e432a GIT binary patch literal 38832 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 F0sx7T?Z^NC literal 0 HcmV?d00001 diff --git a/internal/frontend/share/icons/export.sh b/internal/frontend/share/icons/export.sh new file mode 100755 index 00000000..c352d4cf --- /dev/null +++ b/internal/frontend/share/icons/export.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +# create bitmaps +for shape in rounded rectangle +do + for usage in systray syswarn app + do + group=$shape-$usage + inkscape --without-gui --export-id=$group --export-png=$group.png all_icons.svg + done +done + +# mac icon +png2icns Bridge.icns rounded-app.png + +# windows icon +convert rectangle-app.png -define icon:auto-resize=256,128,64,48,32,16 logo.ico diff --git a/internal/frontend/share/icons/logo.ico b/internal/frontend/share/icons/logo.ico new file mode 100644 index 0000000000000000000000000000000000000000..2b0e2f0f33258b30a54320ce83e433f5ac953666 GIT binary patch literal 569990 zcmeEv2b>f|+WsKt@xSQZ`Q3SUe)r*t3W|~uF)ey#&zvLXj5(c(tl`X(B!lEAs0e}x zB1_J@EG{}uCD2>@Yd6By%pQ%tLFQ)?+<_Q z;e3kkiC_49gMB{VU;paA{^(ae-|vt2`A#{-e?1iUb@{!|*PwxMeY($gaZR7^s;i9q zZ}s^$U*_{Y@PP4uC!g=x?ml1p_WtW9Zm8z_OTB8oZr%LX|L>J*zPDQ+=gZ6UU)Q<$ zINt~Rs{5u*^IxxMb)4^;%YNyrgES#(q|on=lh22=)~e@+yvEP`u3zXl9@kd^LxGJz zg@KLpx^x_T*X5Ud(4cm$;)ZojD{ok**0{!JopBESSI?gWcSm41AaoRT>mt1Gy!@hQ`|*e3?ptmU2xlDbJN+5my`Xz%@!6A)ie~lB661duBMRov6YsZq zOVmH()N2>Yee=Vm!zE z?$V!MHhhR!_tTHScoXvm^bt?q_-}E^c}>Kn=Qk6VpV#ErotL%v0M8B4pPi4mOBO8@ zJPz&I4eXKbZ42_n)OMeW9xpv7K6~O3DG$mN&oT`GY2T7RPwd;YQS9BYUSdDoo9E7! z_Y^=Dlm}&s=U7IRLBR9tCXP4bRq0qeVVq#v0`B#fZ_xAG7A+8m_w5zC*A_|~-oH<5 zMf#CmyXS+_&*w{)EH>}mv3zN$=b8Qh`7w?{l<~n`JH`6Rlf;H8d2sI%tH+G8i=R9V zD!+WlhtE=O#QMC6f_|2~lc!e$ZRbRJ*^kNfnn8{)bTPy$%X-@N9#^yMorY(MXuGj~zPT+pbVXx{J~fpCc> zjDrzqVgCZ|M}Q6C!sUPY!1NBEi@PtsL^P;#nrQdQMMYC$VGhj%?L4X&TKJx(nK+4)JW0v>^h8tVX5>p-HyU$+zkKQ zci$ElK^`*vuSbYQ3koEf1|ndhTh_9A(RLy`*_lk<+ZPXoT`* znUhxtmmJ(?7e4g>b>ZRt`=J;2`Olhr*XmVb9`sA6XP*)`%;z;R!LEXG##WJyD`7%+mc%e9Q;D9LIwpB2Xe4cSIOoH4s!sI)+Gi3N* zb{E?g7DydmwsVI}Gk=&T)*D+3=9}S%2!nc!wuXu?;7r}UY0fNBarm$qwsKS)Iw&^I zoG#NH>UsE6pM(s50n&e9dx;FP{9u`=#6jst`78N^h=bvWmJiE!#}09zWSdw%e5hDD zbdbdI5yJ%KrOQ7=_>@n`@@3jew{Derpj+8K~y!QH#0?L^`4 z+gL0X_3s_34rW-??R>w&>IbHeaWKu42kk4`REzre2^TiQ?`woj{n-Gp*Kf?1q2DO$ zf*xJWYdjO2XTKloXzEO*n|Xd1aIBwkZ>X>t#-o59Hr=dWUISu@gU_;#VpuYKoBEOY z2raA7>E<&`cR<*xf>-ewXO;)+1bbcu#zCI+0RrT$!qv~XN0%9S#kzs-$83P_xoQ{D zKB3L>3BY@JulcMVW(BOHL+~g9@(kqS?Doh-IC;M65=Fim2(zZI!j~iMuu~9N3xMrX zd7uFp&JOe66kyk=v>%6joC!Pwd=0Du_5;TZ&`rN@fL5p{&J^obSBD+Y2p9;I07rq{ zz(^nm@DbJn&#O3jt_GY7bOqJ{A$2zXQ@pQ0owe@MCmtCB`(y=e6xt*Tjd6{5HvxBB z<3|{J+<(J0%a}Y-VMjc(zWm|C_r!GCfq>#vHR(SF{vaih5Ul8X_)v=dp6 z>G3n)QO8aDWEyNB+K!Yx<403Yl$V!F8+Zrmbhh^m8<;#`J+0zrc}FF4JZH*kcBl5@ z{;MxH^WPo$FDpABKKt;$V%5qOVrt$b(X4)5#!Zl0jGsLJNDn7kH|?|!9(_=jFZyJ} zJ8%9+EMB-k%$_+z%HW;1{-fgNc_89p{Cvk;@jO)e$iJUH{ZQO;$wktBKM#4msA)rS zF>pcS`Z8|5lfX*E-w5F_?_ENLC;d^6vd*V{x^vYEu@iE%1^%Pmxwzk#VnmxaWf~Y4 z+gS?a1>en2pL}$PC|p?`;nxAyhKrwl5G%eNA&yp5Nc%6`4{g$AUk#DAGUdRsARov{ zz88EqsUN7Lz}K@7?^6KV5--{@!XYk{aZV%P3CB-=H-XEFK)BiO$xj#9)OxlTj;Y|rjqyGCU> zTE8F$5D@ZWj-ftaQZy%+YU4X7&qa{{*byA<-vX!*^dsl^mCYg#P^92r;4BFzXFp1H~EO>-l>R# zww@lhayNnh$^@o?wk>Tf6`wT!^=r6W0EtKg4h<8uyxMgq!aL(@p!7Hey&i4gWJv>a}$M+pH{WhGB-O z#}&#wPrKiEmSIp&J_WG8V4bxOP~#$WlQScLrvbLrm2L?ex5)KXKZY)X3M7X85qp3l zAP>kjfKyaQF2W`9e7@>MK3@%leFEaBiMVnQXB&}QO>kVg0q_7|_EVhZte*mp*dEBc znP<)YTew*s&jM`s%m=mthXC3k1l{z@2L=Jp0o0Sf1AMSyedx<)zr}BXdw?;3>f1O1 z_gbJAa1n4EAp1CYc6u-#4J`NCz>C070PBc|_8RU{?+f|CcVHs$((>LtYGWMY9HeIi z!0|G@EvUP<;lKBQKLEDk4L7Q#^#{Zs6(AS`)fIUJ{_ zS9c?PJ?-b?dQ!a7t-_(~KYHvTGhIw8`?Lo2>m!B^8X&$JJWve7xo6kTqSa#$i*qrq zrsaWLx=2s6a%*X)uDTD{sN#&*neEQ0?LQN@qCbLdf2NBve&*4K#8G2BZ70UUcJ16L zO*-~fz4*+N)^g~j(yY!%pOQQK5MBVNgQMnmsAnmA&Xu{4Q1Y)^K6Ia@TEUny5eTfs8q)Vdk`{ zvTv6Cvp-K5FWz`L@LfULSx?`irT=WWqpMTVPE%u&Zq6)&QSax9D=^nhl|d7{5A55m z#M$E9bIvsLNZA|uina!AFD3uB;p7zRzk;4!<=8Cy*4%*o#_W$^9=bgLjGT+1^1w8P z0{e|=YtUYz&1as9QNE!#a-j zG;IyqORW2l-j|KE{|4vrQKx@>k&~p_-ovp5JzgVSQZG@rvVLG4$9kH!2LES1jc2GQ z*?wS~nr#c^@Axd`Z)tqP zhHR73j^sPOcwj%-&S9F>LHjhqTN4w3G+{h#Gu_`~;sbHk=S=C`GET%aIJ!4Hr zYd!6j{=*aTAKz>1GwE`jhqV1!rC;^kTJmrn{*SI7F0uLM7|I29X}6@GTw=dsLi^7V zzMC@TJo1)+;<+w*)2;o_cbj!W)V4LAb(6ku_i_w{|DG%|P?<&(0 z&B5n1aBSucAf`GF&s*iJU$dOw0!$qVcQi>ipW*Wsu%6~vK|J$8ea$%uoSP63pP7&t z9oBGhO`DJXkLjZ%BC+C9jT{F`jV{&aD*TdJ`Y(KCK^bF7+m(+5Jcgm3vY;Lki^Z$ciLxB3`8(=k13UF?ll7*Wy z?W#4vNPzmMG4LzEx2Q)K-+A@UV*ZW?D3=L9DG+i@75`CQ>w(XJT7W5Eo9spH#q?S5 zzqoz`7z^wG+{nPqy*m;1IN-5WeS7_Hi^la&LLNH;2Y_&LvHOqvOkCf%_UUnx5z=Lq z3*R5M)0P3$;coI8^}XmXIyT{x50+v+!7PrmML@|tt~HlklL_s!SM=kze(X1(U<1yiz_)wMJEb1P7!(wM3yeJ#VIbu=Y_MrKS7Drs86;1^u{(u1zm=;r*P&X`}APzU<^Pe8?cVe;@VIb5A}d%8Ypt zoDYWvh zu02r4>>1OsFQ~cKpEayyx_mU*REN8ro}P4bvwk1<(fi_Z8{5$C{aTIrE5im26yJ~h zR_-68r{Dy1gULkt^{PJle_s~&tusd@t z&dbj}6H5QEZ{$WLAH=WTtG~cK!!AAP=Erx8^PI6qqV>IyYhJiloTzaDV5Ay)bxk>SI8+ASHXzo`j*D%=FLP{P^&wMv=FZbkZ0><&ye~m|NUa^>`XTloeTcRS`ymkq z#}3$@(&fW(hS4gXc%He3Q=$&e&$7;MqCTR|X1&I`pY{=LNXm(_kX*rg#CAXT@M8ZF z``9R3ZLQ3YkbxW*!I^TZ4{+Y4Sr+kfCtN-nfAlo7?x%f38+(6YXSZBK$@P6}#*PkHvth>%`f=?a_rK#i$GPy-;p#|&e1<{>)8ZkAP+{y?;qR;C z9(zlN4wUO$xJS8@IsZxy<=AhQYiqghIBjOGSLKle`EZRD^-#k2!?mfjb6Nh}H=q08 zMRV}o;<{YElM8$Il>0|gW_tS~0r}W~Flh@VOg?OzQqQg(KQ7>#KhemD&v0+m)uX?Y zVNy}1-?j$zYg_46m8@}YiSlOWkk9b_jT?)|`Zb==$65pb`*{#w10Nstfg_k_-KvXc+@ zSD4$WoqtU_`X}VNzHn#kS4~-1k0i*4_F>;-^M`wY=>4LR;%6Pjb)D8D3Gx|&w7mi( zt$oTo7ESueEAn{@vbhpSO#cSTo%_gJ581Eoa{c}GrNRCD#naKgh|qtCyh%M6#hLvZ z*8w$wg!GRw?fed^+M7VhDeQg>*8D)m{O;DF-MiJ}^2~mcRTw)?p#8&oi~VDX>t99Q zxc5H43#cEg-?{gXQ`?tqT{V@*Ef(I_CO9OL4LytA)8)WLnT zxW^DV!S6%tS+_RizFVA+%e}1<%pI-?(#HqMqX&Svk1tF4f)^Z*;NB~-9_){dasQS` z9H6c97@*!Qb^aUt7R&r3f=7@M_p8zOMRFx8@P_+-Bv>a?cFTc(08%cT;G*0(bKPSk z`9}Z0!6S}K=--%%B&Wlr`{g%SC2tT$Letpwi#AFga`I20d``iCD-s$%^9Oyzes=Cr zbr5z9ze8j_xDOuZ#ca)=FLiZ7(^Sa`+B~NLCUe)S`3=~! zl;aWnjxWEN#X65Vi}$E|)w2ma{|8}9*;K0*xU@<6tk%c}x1qAoeviEJc?x^>cd)D+LH&0raEe}?mwY!K$;L)TTzkqskW7#fW%B~SJW2Ve{6=$D zVHn|&M_zu{_)d{GagmLZC&Dv%2sibS7j-su@$GtERQ}>|*29JSTp$lfhOUT59GnBz z95Bl{9x}0pp@$DQ?IYTd>1=0H*K`E9wj`mlQL@tGftz+V$3SRfCA>|GINWe)7Vf_h zFzsTvRW=fOW;__0KO;0VSu(A|KnQf0l@3PH2~jX?iCmy6J96cK)-LEuX=?q2Z3Xt9KX-|YD%|% z2TCq*#*7R6%IzBA&XsO#;pmgMCdAl4y!v?`ugt=26h~P4PJ4J&_H({JW$lq-ya#b4fXHO z>fbt4aX3f%`y*&0-7?T>+*;Il7it)8rmm*p<^ydEH{U6|S@7XLqz?djfVmH8`{e9H ziYOf&cDly!I|1jj0k#RbHoYH^2dn^CZyo?D0A)W>x0M0>k9EZqfO?R2!L0!2A^ZUd z(rxs!9$X)9!EbS$1H2FX3={(g0ky9w_YN!o_)Y7(fRllMFsyN;>N}2ZJuL9u=0ZsvuRtCtARqlU<-&4R$fOcr2pG2k*Ya77yHco7~Iy%A5qr@Sctj%`0fr-Jii@18x_-|^{4?*ZFrCr2ZH zi|qMb6ON5|PWF?r57do07eto-=br`$|0veUl;9ic+-H5$#*Jbh_K>q41@q^H+y9vL z1l!4Xxs|^r`)Y9C53v2>DcLi<)IDFl{f4-pe%-KjrsTgx{`;|Rsnuf-3+nr8F3k}w zq5t#e%?&7mUD&%P#QyGje7)SrAL&+gEoJ`%u!ni^BDmLAhi9J9Wnt^KlmFhmd&C_# z{u}!epCb1qu6yeLiO%iY2FRa#%-(kWwUKmCfm8WI#=7hu$Mrrh%3Ywg>mzxd3P;)W|P6E|IRg?RkI`^Cb7z;#Aa|JdxI%sNHGPABr$WY02SAEjq`ujJ3V z_r*JI3ED$hlZ}*<9sju&i~QbRQX==9F2g=X`oTJ()#HyuEL+4?9!dTd*|VDd-7~PC{9QNS5YnF2!-iW8C)pN*MfQzw?U{C!E@wM8`TxpY zw?!rYa;$--zL`E{vY0b#rr3_Xm(;=cqTMGSiF0lC-nPh|<$lms@P?-*fA(EYvdf28 z{kZq$`wzDaYkN{&cKqLmb+Y&0ajR&0_L*{z`{$p0T$E~SxmiCyaM$gkVd#Cj)jPK_ zl>9Z>bDV@@Ql7MPLZzGbPmh@a4y&u}3XeOFx;)=sgLzb_M6VH4HKF9vlHhRJIqx?Iy``k{H zaxf0SF@4Uf2oOK=qPWj7a?YX2NWG)yi)HZX6OV-Z-inkb--C!ieH-T6ndUnMxpQ6x z=e7jMf3E+VY%BxzFQoODp)SK%+?4;+_MeHCS6}W_cDfv+adTcp4rQ;)A9+yRKMC&n zvE(VLXSwb_nb6*C)&nE){fn#5KR2Wvf~=yEb-**myo%>>4w{Fe$v?T=hs?SE^P+ye z<(>=NcOl`x9_QR&SLX@Kp#SSHi_6Y!6!$VLrU8$vwmMyipgi4CH$dpHHGPoZy%~`&T))h3}OnORm9@^G*2zz^%YJJDhW@-@T8x*>A0&Y)+14ay*%1`kYt6xh*V9i%dC3zCNJjuFlU`?z27@_v>_)zmf&ZfHItg zz6x@geRk|OW#2XV%`qH~qj4;gG04JL&D+Xym^SeT)2VliGugd)h4?&au?VvgWr1xL0iXp@ZTG<{of=&lO*PCHpVfuH`=I{H|%( zBlYs137P*9pxwD49pz6Rlh6G268X*V43;ceZ2OHX?hDQLgL;WJ{j#ry_`mrCIolqo zm;YwSoObB-KuY@g!|5OH1x+5yej0Fo?f4&Loog#g&69h)uNuYu**nVq1@4J&yC-bw z<gcz;^*v-Mo8kp=?Lk z^}i)i9$Yt(^mebFKaRT;fbT2|?sv`iAW_HO4eQ17;X`E^P{;8bka}8D;NF2a_og7; z%6wDbz-H$+J#FojI2Q)n1xgUQX}qipQY`ivr-f3#8MH~eBbhk3Emfd8~VQ`-ItrT^Ja zz`h9DifpGw1w6}d4TU<=K>lohaV*lbNs{Rf$NvhnS!tilMZYuKsL^09#?0k+FhdkT z8pxmh4?U78b3Lwb@@Jm`zm>&%^k>|-S!U}dP6#3Ww5WgC|CVAf6H=sIwkUFd7un7&YB)l{``YuCY*=Dc~u;7YD!Vy=3r!RN6e0;RxV(z;68=_Z&#scntN>x{2eZu5lq&N4Ii5Bg;3%dWY-p zIOovhzQ%w0as4U#X+x#Z)>rbJgS?*z1jye~29M(Z zq_4#YC===;&OO9j+E@>uI}pTun=;@#kdpQ=;;k%ilmT@H z=Rk8DW#`Hjg1UsZD7h^U)WMWD=WZRqK6spCyJqZY|2i2yuZNd9x5<6VKh(kh3~*gW z9*`dCha73^bDa_G)a4_FN*S|E)=v0Qwq=(M8|?2ZLYn2h>5_JTI%YtYe>P+rO8nAa z-GejNW$-(-=}`tM9V`o@Jk0hN+hXa%b*sF}AVc=^htxZIfvEd%rj6PQu!fhtb|;eo zkRjJsJCZ-kLfwn=8NhNNEA!}Num-X-``L7!hjZf@y$tTbHQ%9Z8Mu=H+p!PnvJ5A4 z>wnsF)I%MCto+@{pgUydO7Daz1MW#SFRCNJxLF_X2C`)kCKo^KH+2- z>whW^<2|4*s><~N^*wb?LV0g3GgSu0nRRkiw2RobjeA{dm363VOBv8MtxD~KDadaV zT?V1#lt_O)ez@62V>>yM`zqL{&Z8@j7j-@7s+oC? zD4-dW^KO-;{mV={J7h-e%_e+z&VlFy&5nx*eAE@m*m3G#RJ>yw+CW9E54W z%|P05K2+Jyvv34y=UC=rz&wC?O80W$SZx9D6kyI3fjeRCs!+0w#b2dYJO2f4mIFD( zdMw?_;V{xP6QJDx2BfvFaauOkeBhdMylw;r0h@qSmjm06!+=`=e)lFL${sb%m$lZMa^)i~5fInFSHspxKDuFaQbQ(xAPhSAw>THcad^et9;Z4cdqhBWTw;SkJW!&84MDnQh>h#+(p{6 zd73*{b5}ZjT2wy$7gfepLwDy@`d1I-&aJ$sniF^B z?N!_5L{|Ded6nV{Sr$c=_tencT3Mf1aAgflWrpiIdmJTmQKG;kdGQEqH@3? zAIOnT8JmoWmq=<&p6fu)?SWIO;RW-Z0vC?Us`+Zw^GWzUGV3=~Jz)6%5@+@$HUw@0 zUIaP<-vTp%mB3bjV_vLxbo+pA`td)mGh(07EZ{qUb^pr%?cBxy+ZeVs0Q`My$3}bL zdqL*+-3<3}IM)Pj20j7!{lnG39-tgJW}NrpTnPLGv;}SlP6et1GR!9R0>9fAZ5qAy z9LppGXjhyK+yVR-7zr!{$PwCpv`?b7AFa=kUk3qlYcVhi_z1Wgs1LAhXPZtZ@>XT6 zo&Ny$^*}FR1yBaK`5xFo+)tkM18xCM1Om9}Bwb#;Cq!AWtyK#kU%mjSQ%ZnT_7Re! zJAgSr7l3?Xdx*L)h)>q?Xna=X_au$-{Ugo~1Ji&*fE&()x|iR~odY}t{29nd9u;&p z_k_^pPR_7B^d#^Nz_w{B`C`>e)@$;KZC>gpuDPOq4B`^@hzKY{lqu_(Yk@qV0tm&0 zSo~Sn%><~Ajt9&%Sb6QGYsgpUi*la~|9gSqz-GW&PF}fYT}u1w0pK5i%pdt9mMv1{ zcM{6-b6_733qPDbdjRot0Zs-~8oWdqnN}rB?aY43CIIbT(Z3w!sn^5#Uy@HJq4RpX7y zFW33=`{s>OV=Fu5729QI+ddT>g1mI@RKxXUKpvov7gQ!?A zf-t?1LUyZG?kT=7`Tdqs<8ja_lpNDup| zIo9E8*o33>i9NszojdHSe-tRLs(jJ&P2Dpcy65gIE_GFoO~JK^*qQCw(+C9yUy2W_EZFP?RANez3+z+wP z$9#5sNr`Cn_#>|Pi?ndeRoX#`;*W(pXW;sKASLCL&hfr<$1QHlPQ;TxB+~MwOT?8q z7r50!ocmEd5&W@m=X_kxPvqv|UR#~dUR37FS$+zPS35izKc`+wR3@o)jyVFAGH>F?`4%xB7>3&~A?>e=OYLJT9&~ z$ktglG9Z6GdEybbWhS{4jz49viyyrE4pIM%f6A~Moq3w9AI~}M6!H3t&x!I7-;^FV zX0%|t&Lx*Wj~9Owcjo!`5xNi8oOhSyP8b=yaQn@ofr~yLRkq>zb3i{nVCMU+&s=CWpfE2j7o>;I7+6 zz0?0G!)AXW$MMlcJLYXq;hwqi@bG`-k zNi<`n;rPS)Y5Wgk#K6A2#h`wD#o+#Z#ka%15{niTgsi)k;CpIOw7*!_?Tv*$7Vcbw z>#V&Kfjg2vXnRe@So?KR&eI6TA6o?z#*;QR?Iky3Cz?JgkCs0c?li}BA!UV*hJwkks zG1IxTXNfY{)z;%MzPHoivrnRFx2tx(xk}rq{?WL@wO8%3oN>k(tDNTG+b|DYb465j zM>zY3{XY-idsjeP>imZ1h|fRzKvdZD{p`Ybo9@5kR-~hr;)I*?YNx#6eHwS@ei%4t zl}q;88K0~M(d~;40esW!<49Z@@034tyL8d{(+oV} zTFMBt|9|3?Kj4bu z4{e?aS?a9P)ot?8BNA9o=8}!DjuIsRP@rUz+o_wgKYyN^a+&hN*4~35h2#&FI+Hzh=Yez3d zsVC$d4vtxngHeuoasQ!5$enQf(YR9`?rzy~4=ne1$%Fks)Io0Na+vG5xR%Qe(4LQy zH;UVn!J9w1;7<-@m7UKMN4v+$J+40;{N`)o;zsqNtxr_hCi=`hthf)FQ{BUTM8C4; zH+vm_U4(sEMVkliO?3Ik8b0@KHTP@{W&D62%I>)e?)_Q5#Gfx#nX*48w>mN6!XCo$#a!{?Mx1R z^G@ri)`g*bo#+wA_po1_`=DVjy=S-|qgY=UR`(RNYp1xwHdkM-aA)PWBgCGfwc_CJ zUE>l+mh_prJTYYw@W z9@Bv|vi;3D%910Y2&DeU`8^=*=Vr{q9IRENM~Y+UgZ0{RaQALm*M_RcD1Vk6ZECj5 z*tU59hxEivaap+MdauHDgDMV@Jb}FQ+57 z4(;757WWTbhbj59UBIIqI_{{UMK zDSw=~hN~Q~KA(2iUgysu2mDdxKwZFo>h2ipxDRs)Id6#N?so2nva$IbpXc5&cVB*q z+{23dpjmW|Ugk%UmWP0F{Gr`ESfx4L&sB*(EDMfxaJ-B>8U%gBxgVTAL@v=j;#^q8 zDaj9VL;?Ttf8NJE8@Tq2d&f}Ua1Sen%kU$VIrrjbpK^c+#A^emyhU zS>C+nI-p^g$H=*`Kf;V0ng0QsLK37Imt4mok9*M9&lX;^$B@Jj_}NSiRHHkRvEvW- zeIKgQ<%M&WKb87OFE7?Zj^inu0D0X((WQzH3u^vDr$80!^| zl zU!#w>uUImuzgRtHl-OD@U$E~OlhNbS9pGX9upP#4uPNTB^JiY-4$~S>{*-RrA_~Wi z6$^TGNB>6$2{jhWc<5$dck%S8;_!a^byRMM)?7zRpZaWUAx5E z@jv+cTEPp&5!P87XH3JG>^iY)&1$i3;sn8Qb8?C@;(5WBJ;bItvqX7_{osNKWAX4f zfB4M_j*+Um>qfXe(dW$aQ;4?y;&{id)vLtPAp-+Au?X`&HqM%X@%udirOo|qS--9O z=|@q}v#b9ZgG;M1{#LqeYry?ZEK~3-3aI{>W&nRwKM?mr z@Z|nsPWeM_Y%R#g{KQ_BG1yjV*TVvqM%1tsn-}j_0lHT zQRUdbAk>io{Mio9GzG2(N;8c=w2^nNS}B$c9$@Mm)?2K<_HW)4s#v4)XIwj0EJt5s zzsk7qKCT+|t+c!C0?h#a@HiAu~@A5cDThx?kn{J^+ckN z3hdvsY4%K6hXvTyKaCIQ7svqq(BA0^cxJwsmFJ2-9Fy|f$nyhiWZuvD(FaPlC7L#Y z;Uhh}*RBcbUy`=_C- z8(8S^zmm2)+IV`pq-_oT#6HJxYj`|T1{}v&=#jMQy!X4o6P5wT8)RLqm*TnX!}JzcY-Ilh+wg?lf1dJV|1ZZS$`9Fp-zpvG#PzY9ON6-(o|0`C zejLDmp5I@CYu{N%cwrskk+K6A^B2AI|AYM%z4II*enuY!_xo9pG|=f+q?Yn@cLzrn=KyLHxqP_XX`K3|Kp{)OX(rb zq0SKgc-DT-@+akXEA3~`w%@b-NjZPme)nwuVU|BB=MQy{XU8wH{7E@~IDRn^@MJsE z4S%@b*JiA@;oOQuVjI4*&M~h@M~2wXbHJTJRe?Vof8sbeIhQ!JmusHcHji{<2!A+# zuJj$$L+6vULHSX+e_|ahjVLHCSE#|KU|~7eZwLh`!HXEYrqnjKmUA^L82Vr7>#sr zj}NX5^Y9!XuKQB^x7+}?_vUdJbLF{5VWfj|ww4YZR5|}RLG@|T&l4i9eP>$t6>spb zc}_q&68Vl8VIRf$9zgMj`yp;N{!8dP8t*u$Q>a^<9Gs_U&R&T-|y_gr;K&wZ87qq_IwhXZzta!%Mgz$%YaChNq+i0zx{6?9R zZ>OYjd2rpb(mm?zSsRFZ6CNmW_6;!FN-Mq@j(QAxV#e#egbv&=CC~93et!EaZgIye z56;60uzePG>7e-Yf5=mzC-a5!+dOx+^)(N9&AklGeO;W(&9=;GPjSs+Io9PkIk*o8 z*ML!PdQ#T@zUDKKcJQW?{Emv>E&G9ncrj1Wti#|QKwLu{wLJ~qP*&WV%E@@U;?}-R z#d42nFUr|S^C+Bu37FsDq1(WNICEUgd+!G3W|S;msEWzanR9~Vo~l@X=vW_sH@ug2 zICY*Ads!uhcJn&y;}X}pGx=rJO7TSzNL}N68n7Pl9DkG!a-$1auG}+~d#bWdQTt(% zA5t$-$B-`uXDAo$L0>fKC+sWce$U>$8`gV$&DU1Q;A}wZ8^s@<4L^=$3VN1%J3t%H9~iEx>j3w1?%lz_A}M_hg8MGdhpA%$w`q z2Pi#w5RHCdyXyhP6U7^KR{l8u1y}$?6GzIQk@Xnt;zO`)(&gB*Zmr~XBKj?pSWYVu z*FS)uy32+`?<6r@apxR4M0FM*9o8L=m287f-79B)8x_3Z%cq_kt9#Ic{|DE8zP02oJgDn|i z>^<#8*#)L~7`Pzq=6rnjE8L+thI0#GFHreTd(bWIA?)$O`K$5zc3?Oz`oAYuP`MAR z2W4g{bFNXo28gJ8$SZXp&YW8|9Pl)Mn0|7IeLI}L;q?0^;W%RZAI4@&(f;SSdVV*o z6GEJsAUot?4Df5f5q~Tkx*X5#12RAs{=PttdvU%;ym-Vuk@$JHHk$HE`hd7hjb9g_2gUf?=EmO1Sd*-mq@*DpE4{o{A8Tp?|A^V@`(qjQwL z;8>R{-9!HHP@Kd0LV)(S3Ntg#C~J;G6=3X(^IAC$#&7cMDOxM%nQ?wH$3nQynB!z@ zhwoamN{)GPyq(`>mfuF@IC|#shGoop=L*1;?(wrHfIr6}jLtx2@Wv(|w2$QYDb8G@ z%5R%;Zu??{x<_hzz8khi|T@jz7kvFM;W2*;aqgKYSMyhMH% zs8e(}c_wr2;d;`Bv2aK7r;DF^MmW#o%rTJ2vd`Y*bs4>-xRVe46U(8(!I|@=#{*uM zO*Z}E%WxXXm}8((*IQP;sjzUq7}ySE%OEplum@#(6%aS>$a>5OZ*kAg47WAl%{hvh zS?2M4m${~t?WMT#N9Pd2{u3|`h-bR9VWnQ4lToH80deP!s?Su|I5z=`fNZ&Eh72~N zEZNT|xii0W_%Tw&2e)T4e1AMGan1hiEQh=;gTpApr}VOQ%L_O6Yw`R7ZcnaRMY^+` zuarR-$b)0$39GlP{8Dk@{6~Ousv`RbdeYC?T1dm}EQdVz z4sqZ3vjD%%)6Kt@)ULb};b+F{kN;d;uL81q$Qv@?8b9{s`7rjEw0g_RL&mRxW3?PN z$#N*YWl(}Lx=|}PYq2KvT2BYuHv>#xmP6?&gIy?_dja#kPbyhLM5d<)?z@2O7)Sc? zhhq~D>9%W#;!KIR4TrLND1A6Yz4MS>Mkyglgh=)D!F>y`3CPxEsg=Ptl)=68wYfME ziZP{sBduoL3ip*jwq2Gw{uDv}=KOb-jTa84Qv=)f+5VyAb7%qNevwuVUZ9SdMm^nd zvyU;`Pn|pt{Q!BNsh5FSFdlN~>4%$R9ULz!1F|+zVr0N?v3COg448F*hbR-2R6QSX zbIkPtfc0{gL-CaX+bT~0T(g%E+)-p&*t3V3A0uy^Ka`z=7C-(>f;=zKd6SQEiA+26 z{K3t6jBf+kzQ*Wz#D1L*0CNl-?o6XiKz^7%g}K;2815Wk0+3xB$2Z^|*pYJe%z z0AXk7#i^&BYUbC-J8kP1fVDu@E($M$4Up06!2bZI9Ov}tkfCe|oh?28hMR59E&%m% zmPdgy;P_Q9pdMiIhVH67;bslm5Cib2E3t;=zAv4 zZqCVN{XPQN3sf~8l_JmI0sjVm1!(d~?p#5=oYLJLC?_d1^5_zv53m);bRMxiItbvq zW8qD$WD9Tq?`3xec)YS5l{|f3YRL7m!-f*Kw}`Fp2D?Lv1RYGNPx1l0oO_1 z4)8r?d&H~jso7`z6~J$a{v8O)t3@2y%P1wVx?_-iL|IcOoen$<(B5YI{|Jz-dWkxd zbJuCpJr2|Xjt2sGv!F-j^&Ll9>cfQe80)3HUR61`M@TCZ5x(tyz~_N&e_i13={xg1GLj_ z1?mF718mb7K6lxFlPQ6DUF7~Uw&jHKqkdwa?K!};z|+8|z)&C$SOOFSI{|Wm^&mM$ z&`m$~Yx16@0QDO=`8n_`a2>$Dy*~rC`UU={DZf1W_hWvt3C$M3gC zUGrSi?^mSzo#6M&)%|KnKO=Rkv3-ZhH`GQ1%`<(g(l`i77 z^DDB+hf}{i>$8wmEPlDx@bG@b;%5ymd!3a)I0@uh|BR7;dDie^d_Rh;;X%JR^|O2T z?fjhT7rXoHbeNrAke+N~qdVaztoen9tzT~7jnY2I3-ptAgq>ee;C(e1hea@Ue$rO4 z2%#pQwPaY@Id*;)A++JMmdx?mQrA}V`vqhKMi%c2$Vd&pUqD7q@cRWMx~AVRAb~l4 zzkmd`@%sfNP?{w8$*q(ik|)!m8x2UF-%m>*lIQo+5>r$8X^F{EepYBQrA+9#MpZ{V1qbfXQ0|(s48;#p= zUmXav8_)pyr<(5+xNtmK%~z|QPr~n!*}t+~6u2ji-iG@P{;vsK0Nf3{33La(2j&24 z0QT{4t^nIbCPVKhFXPx1* z!3pqd4m<}80OkUlfCIo$;23Zi*a0jBMgi{v*8(R4)qsF-Gn*}ubEnG9=FE2a$-u?H zQ$SZ>BCrZz+n#&D#j8(Ag~Kp+1LP_D8F~P%0FEjD1NbGN^If|0F}Ks{(YWsE0p$-q z91k=GS_4yn?EtwXT%Z*9E&;j$D5fHL-%cSHEG{@VwH z<4P?5Q3uWdZUbrnCeKv5GU2T36^rcufa@y(a%vfnh`KIWx{fv-^(?u@`E4fxCeLPm z(atPal<{wX=Ye7%7VfxtwhVFg25JFjTD;CJUB-r+?>EQjUIAtRtaFmZfn>$QHL-Jn zH-Wmqae(j2^O~?+YXSp-3cw9N;&tx=#Ctj5X^v5z7SJZX5TJd&0Z3L^r#c>vKYzKT zSGOGSt0~HLYKnNJ^K&cG&=N4)nO@Oj^Fz-G=UgYY$x>b3Nsqq(-|!oe`_`UIn>O6i z=*-jga!#dtFZlI@S2#8)H`l7K*st&}fb-J4V5=vDcjVUh|9wZ?lyiYdBW@{PlVj9l zXKUQuvhF@QxDz0HMdCG6qPWt>~aqpG?5)IQwZz*o6GwVvz9%}-wrON^NS$W0w z`TGEE4pYum)jbE_L4N6uThdW)DQ>AVZGN*|nGXB{XLN3Hz0{pRepTg?HQ%fkyFUMn zIIrGWqETwviHbW;&+NM}`^QqnG00Kp72UOgzQ94C%Ihr3fc4^pPd*UWU)0=Hy-544 zVV%=lp%LznlU>LDG4{bqTPzj)g6wo|(SE-hSV5U)5h#PVTi)Zk+%Ii@uDJe+e~Ig^ zxYRMOx%6UjN%N+nd4qGLo@!9LmgFaR;#6U($f4v|}VZ9fq=TztMOyG!wFP`^I0Qp?PkrS9b%#gh}kE|jaTw`$^=`za+& z2Y59u%rO*G+kYl*X>p`S*IfH zwCz87?4fAN(GkDKjTvp~D{@NVy!v&;@F9b2xkb;hqet2Dl67(fER6@hEZkz9nAKZR z*qf{$|8wu1QQ7uR?5~9d`Qpu2UKDS<{DK7CAHV;Om@w`Kap=$?TYeopcu>6X^b^sv zAIZHPvGU8pE!y%~dn*cVL2k1B^Zac$i3YHVBIpI%|0CI7B+#*A#{ytWD#Nq9yj=9| z(M>diev2kOc;;{%`NcknkxEY4b9C+HXKubeq5KNRC-vXKvNExB@gnhF>o-O7==$#! z9|IQHyX6`<%Xct)j$RI}Zn+`l{F*a!hPdyxo27jgMVnD^i|3E` zxWzG_w}EWmX^ig|$A&nUFj4j6kt0XMUi8QB-MdGkw6s(lvuQK39l3JFa`D7NEu*#t zjr#O};)|QJ#x3fvdw?oCHy(M9o6e?v&pA1?qoT2QBH3TFrcV?1+*gCITHbN1 zc=xS0#M;6&s%i{4)BmQcuZTK5$irqgyyCs{y9ey`*bMSrA5R%q<*?ZQ)$ZxXqn@i8 ziC^P?7%Tfe$U&O+%xvu$DX>H3Shi#Ye3vqZ11ofVye&Px_y?6GOXX!?9P&+s{? z{L07vRfNGYuEXHI9C5QfvSImUIqQpd;y$iLi^2wr#IH50R*5bh+KJBX+e-B8(nTdY8k`-CJ;;50nmgr}!7K9Xbb$MaWVw|98PLXm_o4d}GJhfzhr;=j zkI`@)+~WRY*pWE$p>d1*KYW?xR^r|*@@sgTH^qhQ*Ni;A7ob-n>924+vion%s+HpU zD0L~g^)?WhT)0Ey*KKfT*JC6=Z|UzW_F)qDUv-&ly%A4-6|G$>o_G*?G>Uo}ykgzR zeH0_|OXC*zZsQ)K*?KX-GGIHh({oRYrgduv=qHp{*lTj3fA3zhuYrB3ZgBYUVX<%D zK2f-4HP(pq6SrP>b+p`K8o7@m_v#GGulfG(yes{co8?w=-YvExC%5}l+=g|DZuXqu zSPti-wtD;#@yufnyTX$XKOpYKn9$`Hoi8~YrQYJR+((gnbw*sDrs=K*aBs}=D>?k4 zoN0%3h7LQ|<=P$cqCxFbW#5D9gK%?>alDcI8UXw<62CNVv9F73GqUw!5@o=-_%k}T z6_3PLPf1;bvQE~y7;$kwSU0*W2i&TXd%C6iouUr==AG8A*KsG2Pdab8S10#_mE1zx zH>B+axhk8B{So|5Tvm6bNCvdUI<$Jy3w!7c0=Ky5up8ZVDY#Xo_jgN&cZ+*~y?NhV zqKV7)B+6RXF9~rQ_8Rwtt?7hY1(kcBa&Mb~=^$TMY3R~X%mZ9KlOo+@rctK3L&!8! zhfRkq_8jI;CxbIi;~=-VAM91lo1P0uy+w|NJCJ8pH*yaWels;>zSHhMl+%(y{l&(a z)5Z3si^cY3OFV&+#S2Bz&p(L;Uv?MsL%bX07}wsCW7K1A`}Uo3#_4_RH{pJ;*mHO< z_O)(7-f(~ZNC)r3`%jK_OgsLB<2uS^@xZ>Kbn6zop$<>@l`L5#@_Td%`93Q>#=27O zX^1j*%7=K}M_yBJaX(n@Im|W)c+wlF4uqu_|2J?N&g+1Xc}=(f=D|iOo;Jl3JPMV_ z3ak-ZK4Mtd`kfr3Jw}_K`-8>M59*qm)LG;;=Wuh+VS`)b2=~f82ax;;MY8#o^KYI{ zH~vIQ4{ffk3-Uu1ohSS`7k>4aQG)MEr2LX+?7Lw982ez|^z*ybWz<&$_v-9|{b0#& z#&1buYg~uqm-bDA-{Zb1mS?SDrQLO=@N11jezAP0KiCJ`8D-9~B#u{7pT)~IBWKAg z>dDraFRh?gYSy7)6oNLYb`~%*2Rjy0o+Be$fT*J$C;@pdX`(1EP74F-^y`k6# z!m!9y7rat@Ta9P=ZQcMKrmk_m2B4l(_j~4?<(GY#DZR$EZ{(bnGm?w=&-_2OvM&dB zZU;i~%ae5@--j%}?D>@fT@h4A8hraB&T~_cp1A19MDmM!&K$uSn*-ZRMA^_7|#Zp_qUZ9Q(-Z#2SZnKm90{4jm{K^zI=R_UR@2 zKsQXCEcRi4+@sjTC|*Z~@Qdp+_)SH{D|Plb`jL>sUpF*tv3rc0(I2$thwsJyEt|tqgtk3(TBXkO z*P{hJy9(O>N3egJnigK}i$zoae>H25!zE zhxhN7dXDo0RamT7S9~*E?AuuEm|_{mFRnQwzy2Ta1Ix(EaKo=d*dKD;#0i3WO3oJr z7o`0(ZmcN7_jsZ@l>LVJTQW?8^Q>dHZR zb!)}4uLhfXhR?;T{YU;#m$5CtI#O|$?N0V%lFhmEV06(as7>3Y$O46ZW5REdbL+x$?~Gohz5y?y1XQGK^n)knbGeX5b)Y z;AMp4SLxQRVwGmY^IrCivTayi7J6?r8|t|7qP|))_Itlv$uq{=1GfK^JlOvGgTl%% zejNdyS^_TsN4?A~rrnNTY~RsVU(~-(pdK0AAML)C@;eqmg&b#(io=J*=DD*2`aa0B z6<>cP$CvczGK^n*uW|v;w;k>Hb!ZQ5`0)WYJnvgGc63BLNH6nvx%U*o?i&_p_bu$x zQ*4<(*R=gJkY8L^^cCgqbx?n8TTmeBuzPJ`P+79?VAJfG37h-igolUr>=tXs|KJ~I zLVBhB58I!8ZLCWVy4i?608iV%jKso@x87TjtF%ZJR{#B$jYFS7Y5z zun~=U86M(|(|3UTOYF#`e6g-!dy(UUUN~4^vVCdx$7X;ntnrEC+?n%jDsXG@fWCrt zkCzVG^_)K@IhI-cV!xLs*9&Ryh^ebNzed{eULu!~R&tE(TQ!H#E`J{NV?V1|MqYKZ zUR<9yF)iAN(bAjq{b}>7cBNO#!jkTc;g{$4H-Qc-95*JD`oE~RJU{lx_l57g_xW`+ z6Zw@9_I;LLX=&rozTa-CFFk3CW%-qs{9^w^M)a>{`IVOZV*hGJj1Oh`m6rTk2YzM5 z__iBt8o3e8=Sfr_&dNo z=Mt`C5*bHUf2Cdd6{5UP1-L)J*hH3nGQ!UCEA9BjelPA1z&$8DyRWa}Zf5W+qvlUw zpWiOz7V(fOC$FBl=7#h0oE+RMVA)qgk}}VV_7~S{J2|)>c-5$HGi83xvj(@wuV#R^ z_m^Wo6Xs5FjjEG_dyph!ogw$=*|lc1lO$nplzmvI)-NgjCJELf?2q7ghLryL3*6qe z@zGbhWXJ%=d%AIsexm2Qv(8>NY!LP~cec;mUhGHB{SXse_Q?pJw*5aeesO=_AzsfH z`NA@Ca&XhHfxuny}e*Z(_75AWeRB68B}<$&~$2K4=XsmEAfdOB|`n@!k*$?36o zeG$eULw}Pv7I{eB1sjiJolXw&eHHeK%8<3&Z$fsyd6o5}vcb4#lV>@`GF|@lFvojL za7=LHjA`-DFVaEYx>?J_J*4FNa}Uek;LZV@P1}nf@gUcLKLE3V_{%$qVRDT<_ZfG3 zuq`RqRK%-ZLO8VPxyOW?{Vv${&5*j0^9gCso1f^T+rVcydzxQt6LQVH({?0z$bFu< zPle2T3_K%0XajD>z66|O=JeoRbDS@fA^hsD_@oG?u5rE^@b-Ellri_F;k-dNNBN%O@1CCmIceY=;sNpbQt+SdyM@SYsQS0dWgC(q)st7#s3%f{Zj7f8_hmFwEy*e z=R7X^l}P{p0>1g(D(gq`O#1nG=lMF4WyF1s+}L5dzF|K)`#IR}&b_9o)AW6xXkT&P z@s;0>kbMee+p(v)gZZg?9Lg>C1W3bolroG0tQ)HVN_ClM`Wa|}^L`+j@YCiQ>J@(9 zz-iy6DsSt#{9qaC+O6`NFWiHqWbs15{SoDNAkn7~%fWBVkf+I*=a_`@q+N9_5KuQN zg3ayRQSpmo-g!w#Pg1zZ@3gqF^<&|Sn`hakSONVPvi|kn`#lGo`BNx|fh%<*@k?duLLQaZgtCt5b(0`{3Lge#bk*^cFeCd0j0G&V=O_|I}=-U*MVk zKserd>pyUed+Dy7Fb;c4?@A8Ej6SvH_+Ceb*;}gYzXnHYkYD)@^p~GwrVh)&bDkda zwtGL=W?O=9#FQ*rC^-I@sN>-7-J*Ef6lwouP&-laif4{xUjxWGFQW1?Zcz8*d^|7+ z(8J6OH#o++YQ;BSiS0|5*nV#+8s2dXfPG`^H#O&?GOkRNVb?E4B457(T=C1I!!E@0 zyE0Th{{AQMZ0XQ}()Q-~(2)ZN(N!J>(j{6U1*%aqc|7!(Fy>r{F#~1ix|1?;f)~x)Jjk zSC1Jbzq3r+PR{AiY+flIuwFCgs5-41_4^bzajpjx11jvyK9g(4*fu%Cu}qHRP%rUY zw+nmsH0SQi`#9gCYH&)GJJ6GvfQegGT^sf?gM;tySDc& z=+#xHNz`tJXMp_`nQ!Y?P3}0+8MNuSUrF3_mm;MK6K9Tna}K|g^knbJ967B~kPey0ZUghK#zQtYhlya>_%E zV_f4lG0U-xf5&E^jQwjiP&X#}DClnP>BKs-36L4{5Hmy1#Jj%Ogz~)zkoFPR`zPa2 z@fqhkaQ5`xPDp>e%Ow-SRNOdojz$+CTQ->?gMKJWt|Lnsw`@3eBGNM=%Q4TtV`EW<91E1(n$fncD(aM+ z=lR!y$h06$Y-daWvU<#$GMI+4s|CpNWZ9;}q0(cVPs4dCkmZ=SWH1+HX09<~S*Fv$ z^k}F9_jDl3F>lCVKFX@TR(|P3Ei)~8`rszVvU)82`9-~z_S{k=vv7=cW!4@`Uykwp zPun_CamsqOaEvy8wq2Qi92@0-okpCCB7mDJn0=Azya_*)j3t^J^pI{s2%N z@RtYnMa`7M%#%NFoOh9(+mk$wv9IU?y$oj0o}DSwGV`J54{pvM;~d58JlMqXjPp)8 zE_^be>JYO$GSQ*u6K?A2+khoN)+URu47iu`eSkUd6z)uWGpd=3<>mL{^xb?pk#17u5SUjPi&TB(aC_{F1Q(8Ek>9I3MsIfRbaC=ax=>t6Xr+F)`Me8M`+W z@)qm+!+GhP3)o~%kN#O+>4Xa5hOBfs(f+>(7z||R_*e3AKjgtNw|@b&D@>Wq>)t(t zWV7B%0`t2B^vP0oYy-6b1^_#NSl@fEKEE4z9|l|jnCtZ6mi$uk%buM{K*>!z{{rr2 zKnGwQ;8mWuP4@=mwJX5ABm?>zaP3T(*}FnYfHDk(+?T8t{GJ0Q1DV)I%f6Z!z$-v) zfa|mZrMsq2Bi+4R|XsAMuLsq?!9?BS`u z>wiR9H3yytXcI33(qV28+kPtmw*OuRXj7dC=xO)6yLZm4!t!`-@`Kx*s?6BWaS}kj zJp!6aJi=i`03Hhxww+-FG&?g&bX^s^*I z`UOgWfd?f|`sMMjfWszvcsAe`d131poMfL>dVE*}C)wUFI2%5zltChUzdV^t?FaV- z`JsGswOgbUepW9Z_!8uIf-g9`c78P_{R1D*ap+gm7c44!zhF_F;B)3z!&elTI9xgP ztM1DSde*sLZqT#UeDU@RiX!Wgl|UE?APCPMjzPu+3O9b zddN~)BDK!-U$8!HW20kX9|Xxj*!$&LZlt}D=fID)N|EJ0+B9~4Rwa7^?z0MuHj#rj6mJMa*brcp4QLt>3Rw z3Xp)|R~bo;=~o#^8`IB>L`LBcPpF(Q7e;udO;l6!Q{s|d#?+}G-9~$jN&iP{>$vME4 z0Q*Va0eS(Wfms0iGT0v71F+A8?Nw`^$x-7!-m@841==`y9vv1}U;1b{opgr(Cun-`>*$14+wa|QTN`WoFV&DgW{W{M8mjfJI z{taNh1G?MkcXFMUyPub}b#9XLO@P;cvA}xZARt_V??oxF65#vr7*HFi4(RbYO=|YO zFcQ!?VsZZt*JlIw0PTREfg*r?j8^@f>TA|3#Q^73d;zos8UQB%w(mn2k%axr%0XQJ z1!xJ30d@jz`R?R?)(`!GYk=PZ+4mr9DTV(t1SdG&^MAk%z-Pczpah_8l4^dtjh|yl z97mc7aNO!v;GaP7_)#}!bG@2aI0p{?8F&Vm573@*!sA%)DMLKtfSZ9|1C{SV?ONdr zy^8;vAG{`odXaoM9e5BJ1grr#j^vha>3%=$vUR|4fbEDgf#ZRIx&hw;wq*%<)}XG* zwHmm-4VVegR*99<(L7JP>KovKre~VzlJA0T2CA^9wT$Qb_r~S;C+(V{0O!F{U#2^U za?A^1(Y7lDx&!RL`aNLE?%x-l=jYU!XH)~fZUB}5 z@#b(m!lO;p0I1UMz@pCW&9c+V@npE!FF65VAB`7ynTj-WKG9_0X`trfo?U$%T0KcV z(cXC(*bT&ktBDF@HPUxOW6d6_D*AtJx4?e-C7;Uqvvp98Zv#sJFScP*@eZgsR~B^Z z^xv=Ee&bwl>NDU-nsZ;hQ#+8(M}Xr1Sr=sTJ21CvAn)<5GupBBfX{*TfGW@I*@Z0f zyLA%NJGK?i+;YS2MrWR$tiBz^;aJc6!2PEI+H4v74uIQMTZeu1?5AhjF3a~QWq@%q zG3$$V;?=uu6HV&Yj+Kk4dYw%1G57_YjRQUZD;P_9L_c6)D{6Q|IyaU{;&BFZ?as3>?`88SI$3X_Pd%C{RN}ON6u7KSWiCipX9>j`B0G2_I0a-TLo+q)0{`>IUizIq4RG(D@1r|cjL_c`~HefXRgoV9i0ECcHP zwog4KntHdrt9Tyj%yHuLRa+pP>wtMZgY5jX!1*CSCeF)A`MXS;f80m!i)$}v7L)F8 zP`j3>f5xd%qXC{}7!B*x5{>GdmWcKT=L>O;cG~udg5$cqLp%RoAglA^tnVSyfcLKT z{ddR2x8@Db5zjyMxM=;_%i^t9UvdfmGp|11N<8uZ+xreUE2@M0HEKvVUrF*$`GUq0 zOH^#w1siH<s@{jV0C?V~r)U_OA3^Ehs38C@6@q6cu3$ZRus{on2trU3P){Kj*%= zvorH%-oAM=ug;y{@4S0w=FQA4=iFXyxb zbqYAdww;3ES+4qp{C^T{{Xx=K#s6hXV*HOy)eY~Lx+tCgjriL&SB9Yhj$6GbSMmVx z!dl+p{FCc}mAT57LUI?mKk?NUMaQ#F3smk;H~*EAm?zwN!*yY3U?#s#m?^XK3aSr!f{^!8yX(stk4fN>pT3|PW`?qF`{|@f6ZSxBiC)&(R z-p8`fgjZe&+~x@m$+`pUzb%#jSts7OVS`v%RV7xgXvUh=t3*w8wX8Ff-z^=Lb7qUP zPdO=2odxdZx)S?kIsVZGfc3p^D4A?zVz%VbxSeaOw9X{|9o*+W4vVdE zqV1W}0r~&zEjI>DLw)hT5&XaFmcNN(4n0VIm*pmc?V86KeP<8<>mE^GU!PC{yLL5* zJJ3(SegUPy6ilu!^oLCG-@$#ZFQUgPXRxgAOj}s?zw?qmiZ&s%sV7_hZ``#@+;!_s zp1e)=J-hIpJ8!&ys}c|F&&4;+o4)Zx5FKGw*me-TZgq{(8;z%yHct z(ee+|ZI)Y(Ipn}tnTO>bqwGUX5dZo2gR&1ekpSjb&K2jJ+BOWg&qyc#9o*+0TRPp( zVwp#2fnz;KJyR^&9e+$19Fp%yru~QI|7SWqE-wG$MdHei7n@*OekbyU{=IvO&6_qQ zqQ1P>qw{M)+y7O)VO6^L@8JIVIM?O<9N<1Rz`F0SCmxk^yi^$`h}m3jX?grZ?T#Iy zdh1qEUEPfB+qMbj4app37zc4dyVFC_E&RMA9sHNv$C{Fc2H3`_+j(+?`-=Z>qV4nK zqmNMOU?9^E|C8ak*MFvcIaTHzA&rBhpPRzwe`zn-rCG94UqruJpONSY6yM5OiR;U{#n0n zop|}VXT&MtuSu~p1lr42Sh&wI%ufM&EL)D2``G^T;ZskDGmkqul=Xd*<^P^NyTzxU zd@TC*=qCF0!q`(_K%ZV>#ISe7yt#7($N765Y&&8Z$WnF*!@fQY{#&@u^q=z;^%&+H zF7Ri_ePl+8!TDJ4&+rSK}X!M*F{N@2AP<&&Ay158oT^MSppH)5ZgOuKmB9H-Fs~ zmkPFpCOX(h5F-Ca1?4|@uFCw}1F=N&K6iOUTc)@vc zd~_oIe?4`IIJM31!YKRj9WMmozlHnz!Op!9b-JG?VZb`T6E|F&@$~<0jDI`UW&Gzc zhaM>Yb=U1Jj(w;^J)vFO)aUA3>JJUDUzsZZ$$jLbD($}-=iEC%^FL3*fNeniAN*I~ zxsT*ivgIF+|9|BEd*$4f^G-iSNMPQ~#pj(Z9{9&yV&VLGiIRSKZ&rD^IP=6dVbohz z!tF-{;XgG1aE}SS{%oG`UZqj219Q!*^8>7VqSDf2`Oo=Z>(;ChOBOGZYo&0#lx0hn zh;?h%Bt8CN^r(-*T9bVY-2dHF_;2u@-2V>1dAmB@&%-d7ZqxyS)+dwYe~XEG3xC+X zdpFkAdcd{*thDIE0j17u7~`;5!j{qpP%|?8L0E!w+F6EW{UsQzM3l9hcjlI zdvu?e691da{D*?~^ELPLllzP}RR-!1kW-o9|B^+E#I+%;Rbk{0+^1fy1L`LOK$tPj zC)nQ83()1B0tkcque>Z!o4Vrv@})~#D8cx@&2iC_Cr*%Sf(9+88QkYyW;X!7*T6Nn zPySy7Y}fqHfBmx^6ni|1vp5z4{jdf{e3^WX>vNXPm@c+#-Yhn4+?X=BKFX?R@xt@u`fgnNnOaoiBWMo+ob!G+fV4mPeUQp~n&lp*{dWR-Y+C`8dldhf z2fSbWxM+XEu_@;Pb+kOyEs@&fi_DU6Q+AN$rfN}ZQ zU#||te`+9y!F8Yi;+OlSJ$oqb(=67UFD%Zh_+(qqc&rI=Db{5RV@zBQ>R>S3)XFda z5k89l?B}gg;ib*O(*estgC4$5oDrZdoP)e)o-`LY)Gznde8!4CQUfn(&KDl%opH)_ z;hy~4U&9(>kPGFZYVba`2lin{6`gRzp-DISBWzUK&w1>6Pl^Jn^E!F2&RGuPKB#wL zZNyaDL&3LP;62^Y2s{Yv0|aUBe_n-8!JA0`$_f=p>X#be+UVD#9W!6+0?;n|xn2Q$ zm-(4NfaeN_z`nH5dCz>m9OGeT4eBRm4IWUCm^q+ttiPW6Zb1vI3viC)H66~$D-B>z zJ6U$S`?8MJ=+`^B>p$)&`qP=GCSB%Hf3%G4|94AamG}1%*GtBHEUK3;6?I!Si+Ze0 zR*2Y%`Li46%o1~lz8TBolGFh6fqWtsyu zS*87)(>OEl`JEu1neH$BY_!;oeK(_W?7(>Y*>4O?S_3Q#u|9Aj_J+?@9k85s8rDa7 z3VT^j>-M@F+Xvoq-N6F^Um8&VhsnJdcLOcvm-+giihjMs_EjsRLVeSpE2m5n)P6GY zrs~9O7h>CJuGItFwne&YOoi@*K>d(!-`=wp*Gm4_Gtk zwLAYV+iU4A53bhXI>##~l?SN*!F(C;dS_u=*ZfZ@9+?-A8yqu-b{26K_7TX04w&AP z`yDW6`SI(ok@J_ydq;W4%}@Q{-hF_C)n7}S&(HDSx%6W}L-RjS{E~YdJ3ah~$He_t zT`tSuXm1Zy3*jV+aSvwfZ#aYB`8)c<+Ty;)ht;U3-n?*FHl`09(Y zjPHZ@%71&o#an=6`2Q{ZZ*b0YHwfN@^N2E?#;^miP`qbdN<#j0`IN<8VOE+n;LLCc-_E(FblrF z7k-rO-@5*ro;+S@fq4VhTI718>`&&p(_DL<`wwsr4(>z4y_h~i`wjQxW51Q+dkCDj z@}GOpaU7d#9-ywnx92KNL?2)zthC!litCb_BS+Ke_Hacnx5?PjuGw$uj@B9$>bPRNMc-k9_34 za`Otf|6l-I?`ldQb}spUA?)>l`hQ`YndU$E-hlP(w=P{QR!{p%EFC*WEdOGhSYJ^l zwy$0#n(FH_CWnafpXK_$D84HJsB@T41BK0btN${@|GikZa@)!kqH4nD=x-j3a&8Y% ziaMbJ=NoVw(){<{6>DdFBgdYmTL=;5Ke^pq@!i3Hj`ta_`5!Lx>Nal@%RV0~D*E@9 ze3x@;tQxe~<#;ry$-!cMd0AS=q(+$k)4}l{Qv*Coo7eU^4A<6Z{)fYVrumz(p8edn zhoC*LOI)5xu3OW8Te#yM&zFoIB{*h39Y=)uzXf4?JRsXaE#{@LHvw^RRj}7&S{wfO zH;yM=QSyZ-@7ptOj6Jo$>+|0qhVsyOv25I@QX>pwnf_bCpXVH(x~UR-BSKGM9uek0 z`+{!*ocw>TV0mugl+BN6;D61EDp`(qmUGDE3h-XeGh4RQtn0E3h;`x(vns^A;X`9- z5B_Vq6L!waWZj`*TTN&Zh%o;dwmktW|Jg5G*kk?OG!P>HcQ@3FwbQ4GnFIUU({Y2# z3qE{bw)^a9YVzc8V{NTiS2hD_{vi81;CAYV`rsT*5kk9Z#QD!T0oF{J#Nq)(e`$ISTdw{AZb+_1E$~_Bt-pXx3S| z&cYt-cNp~GcUF{4ko^OEw~8Z`x5)1#C{uZTKjQpnIQ|n*>3?+Ax3cOw75<~_T~oCT zW$|}xWfPf3WBeBDw>z=_Lzu_jJ$sNBED)9NycPFN<}=I3j};9y)xJKd`7ie`BLBIU z(R+ZIwnWD6lm8r_u;!buWf|O=7S9^gUzYQCW51qs9bA)W+2^0hvX!+=M7LB9ds|d5 zU4p7{a$|}#|GSL?&)Mh8{an6@4Ck#n_rd?#4eQ0Ck4MJR2s~$o7wii@cu$VsNf+;} z@7>eXC^pQVDP|8DV9N`r0roMk{`yPNRM&D{Tk~J;WAO{bBkRFcW_()tQRwz0_`hd& zlh`~C8B#pkZ&4US{u_y(5EvvoA?+$mN~oh0kV&hpUgAp?<5pbqX) zH`e^`wiBHHBf$AyyEOkTEM^^dIqSGXZDkAJW1P5l0$RfenV_syfEqZm5^xd>owFa z>%VfWHQcP?)23acZmjt)`TqvO=p7YKkuxQ)hhV-*wH#wP+og`nFj zTpPU(`>?YwPNr#&yny5DWLqx!>mrxt+2X%j8T?N~I~*+hU|5)C=4DGl8EcuXe1p2> zd`f3sMz$3>{2WmZaGk5g5%s&L=9hf>iKySYHCwa|{kzqeZ&3Npn~w5O#M8W3n!gd; zkJg+=&%7?yUbt%NdZvX@xmS+9+_k0C1;-s1>cM%L zoKtM=D~n3}diaxTJW~Ua=0DfnTZ6e<+3d^Bl;*hC2j>p3juZ8GH~b%+^{(7>4jyn! zCC8OT!bCPL;C$WM_3NWTebb+uXRP@jasDIikLvp8>?14F?f*>o zqg(!oH2)WB{>REa(XIb#{ztalYn1sp?lZdWpPK(s=Rey&quc+b`5$%uv+T3T$P1zu z7MlOji*tKh=LT@URP#Uc^LNdL$ShenmqEAx$bB9^*ZkM~k0=M2_79Dy-(BZFCYv1Z zShHHrf8V@dKIW2S5t#eSaXo$yo&L+dFV25?P4nMp`j0tQ95ccFE3zGS9J{o2$zs3s zmvcFF`KQ|u#NYjZp8xKLK{>>Ka#-^}KkBL~pw zeYaLs?s|A=vbZJ%8KoO=K~8!^Af(l1^9DTH)9RX_AFxKI9H4@9pG zRP#So?i9jv_gMIU3ha9SyJU+%1yTNw^!zWj_jj=HpY?!+Kq2Tlc|Rci=RPXjSIFwK|tN=Z)WH%s9+8Q=Xrp!iSonJl>_>m1@*2!0Rl)gHGWV}{d@(w<$g z|I1{*PM7<#axJ(BuL0EE!F}@o5`go;vdYIyf1hO-uJ@VB+E-j_Kbsuly0+X0%5G;a*<5wr}%GBkT#VS!0-i z?*E(Va?o0=SDMPfxiS%7`>-py@41pA%(F9VT-+c6i6`;BHIAE3^b zjQIp>wAZIXe>qnz!eyT(aQkv{pRgXrwf_gR63A+LoUQL~oI5)uPVu2Fixvv5C7MnR zkoyZhd>{Ln)}-XlJy_eFdm=@+>{A799Rw7W8D)Q^0eP)gw|!vt10u(Na*J((yLatM ziCZxZERg+aX(7It{vOOsxPOYjix{NhB@*n=s@w5d(gAJl4X}5 z2iuBf4eXbaTkt~h+O7oQ`sGViwK8 zt(;sUs+TVnP4#uQ*91A?T0a#iL(zW`#UA+kZ-8goPcf+bFnnLzAQ7E&{@$LZCe(R9k8+t`rQf~5Wwvd`m3f>Olty5l3RDB?9nmt82XJqs z+70=;H`w}$@~n^1&H?^#ofC7ei<9rp^YY$iJJ5IV+^8IIf7W!zGQ)xq!&B~W%4P;3 z`{UTwZ`3g&9FHaN`;P$hd3ATv-Su4lH&#}<7WPQ?1;F1@chtb5k4J`AAI^X%Sbyfc zHqCuGr~OXyxipEgj}n80|387fOmQKyCN(f>B=Ujk46rxYC)j_Nsj*0Tzz5FhWq($S z^EV^qo-yvz4C7E*g83^Ty5r(}a7<}|V>`J<%7PEyL;5{Vt{1^}$}I~Q z$TcUnE?y+ZTXU=e*Ropr>1e?*|8lH8zNb0wo!+>l#hkaoa!M#^ztYZcTmMq7m14a2 z?=W`*N^jb1DJ+b9fn2un8D%Cl7hUSbxKFzd-<0^pzN%PxC8V@pX~2AMZ0(dtt`np= znGz>6rRA>?KR;DGRlE(;RPTm)K2QT>%5VBRI;<0MfNS<#283GfacW1sALhOQ`xg?q zN#E1qlPL}i0T;gwq?P~FgZc){-vHc0EK`2e-_c?1#(`zvV4!gh&U8M}Ip&cTRrKtl zbZ{NMU9UBu(|{~-fMfIT0wf>Hd!;tcArar*?y2v=Yy}Jg+;pk0>2OIm2e_ug4}i3n zdz?B@--LN6Pzj{luloHujIA7Cx#w6QfDCbf``6tJ zDE>K<=Zu_NzR&icmjSCjwOxltx;W4mTw~cgD`~$~T*?hFe*%0Cq|49xy*g~k0rqoo z98T7`PaVwY+0)E};HKjNJw{aXG+k*G$9A5jxL+3X7zb;*?#FJrC#FfRAH5w&R~YE` zMl)>L&VH+vYkoMI?*Cfc#d!yh1A2^HGNLN_s z_eLvhhl5K$26CdzpGX_ZjWD@}?x%p}st$u79GC(QwFV^ju@+e(`epqd*V$5Tg~_=H z(}5uV*Uv^b{5ZbM?BgoS(D;U+B7&Q(8aNT>^R)(a8sL)yRrr59z~oX;US{%ntGi)u z4=e|CTCT$&84j!iS1tzflKV<$R`5{0Sbcq2{fSdf#|01UU@sy{et7ZhXz;{S`K)Hg}xrGu;I9df+_PT&O1%` z(ahIiZx85pp=j~kEnQcC4DV_!-Z|$Hv#(rn)^xshG`8G z*iQh)1DfxJ5e8o(F3$i=-j~C!4|@n$!v}WGCw>K}1+)(GGYskxcfEjL0w(wEv98ao zAz%$B*txF6Uw}$LYaqYEU zY#}hCcMn_If^g=V0L*VWjznu9vth6hVSX?0zknGp+@}449U@3UD2geE?f{+ag1sWGw{Bdd1ZZ7*EWj9|kG_ zT~AJ42YV1+a{-nq51%v8&iT^bU9%R?`ok@U0OHJ+M&X?M!d?e_0_b*AZyj*#E!)&? z0e%74!ttBFJz5TY{g0dp0cZT-{JX$u0Qc%(`o_M>sF#nJFD?MO0PO*eL$~q%#hY%* znK;!B@gD-t_%zP{AI!^v!N5`=O64P#Z&nCzK1WJ3^_Ucga z%sB@K0#^eAf%yQ(Ef!k7unT@#1iT5{0I+VfFQCFfnx%QN#%3<_EFawFjCbRl?HfM_ zE(D$dz5vz$O+dcsf%(~bU=r{mz_rJI1yJuc&huO!(G7u8^jW%b0@pZ>i+u{00nY=U z0*iqiK(6Vb7H(qy-#CEvn=64Mfw=NKu1Wt!H^#Nk6NP}{hl|+?*M13{2HXg+uZ?>! zPX}0@X4?|W8GC_r=%W$u-3F`#%7Bpo=LoUA<}BdXz;^&wJw$BCBY^(}Y-7%FZgXozpPq64(HHC?Ak2f;d9u5xsFQh7h$>!hg$uA8(0qL_ zFUx-x7ZvT>e7%ZS`GEXG^Yz$ee4&B1PU;3e7CTzk&}^-)SH-U4MJ2SEA1>w@k9~^y zOLy@wT*FK9*e7;b$d{^%G>z+3)@zpQ#m?)U9CuxMEdH}G51WD3{Fsb^c<01ormFdW$|eJ-M4dI8@_KRexw6UY>cncozb?*{ z0qx>UdELdC@_Kxz;X`rLb=%*}6ZhCE+Q;*{dob+d5e)l!UUv_kRvy9D%01ShmnzdM zR?7kIY=G`ctba4@?L30h>v|`5_8?l!SK2Hd!CBPFRV+vl-GjNPozLs7-Pu{>dEGr) ziVpC5y~^f&^Q2W+*GtS-+ARBqb-md3TJt29&6uaACA9128?{A;Kme>C5Z= zCGOu1Ej_7ne}Aj6uKP`&z5eevzwpS9T60KA57}ei6k4bO2#IRAD zjq^b3*mvVR&@Og8&I6rd*W)~3Ty}cEaDJQz4ClvD*KmFu|hU3WONle%tq zkm($UgG}c*Le2;^zRVVKtIywI@A;2mH(uoBn~Gy!{oJwP3>5vT;l0Ivbp18smG0?qF~DrOJpWc_#&A>ibbIyZRt zL!2K5aD2tRKrdh{z_F+ttH3^7H?D`(m&SMR1lY$g4;T;h1s(t{2H3B_wP`sH#HI^% zo{6)9>MY8LquC1h^I8eiq*Yoc>M}RJMCg4&t0TxE5d<-T)-S>oESa6yJIlI26#j zu*FhxCvH5zHI7GX3p@mT2C(eD7sxbsGx5ER@E6C>J`S7){0K-|8xG#>i}MSBu|Ojb z2FFu*#}a(|0pNcCQy0lGB6|OFS<961|80QvUus|iuocJzA9Lv&thY}AsEv~V=8ZOO z%df=+di=YX%8{GkH0K(vIy57OI z+qVGbVUg7Y;?7LZ3_I%u7Xj}8n}A&AVJ^C@8e#Uq%sxH--0hw_e}4Rthdl*;)dT6` zaoF#j2)7;?(?qNc6@?4|apu$k+u#2Jux`w@`ds8%Ubv~ecX#ns*VpQ9`_u2|9e2c` z>ma0bptNUK@$}6%NG+hOluo{8 z;@uOW39~*?D1rdrt$701Z8!sX576bJkaU2s6f7t8y!UQ#+ObE8YT*YsXB+u^t%LB>0p<%+I=>=rxaj;s(}L0*`zOu;WIJp=>A|4`YJz>J9|BE) z&J)rQ2GqmzxBg9_uR-yr5X}51Ihqt$6~+*xV1S(K<+9I>2($sAr1B`6wp^ zsWb4|FaXY`ON$)a!SPNqk2w11qjOdgWzBoVDm@$r4SWP}u8htT(ijFa(4O?Kzg!xq zoWy+PyfaP_7oB~kxUhX{xS-wX;_OpS5~m$^w9KcDJNyvQ=CI$0<9>T^Ae}n&#W8^9 zxY%421o*BrK$GL`{st@uv<}i72Fx4Y@AQ~xclL@qu_2)5Gzia?b1!RS^HGO`A4~#~*qi z)jYxZ9Ou~;9e?C+vo43yzFn+&0_QVc3Fz`sn#)Gcw1#?k{_i&h(gM@nOzD9N*}8R$ zxa-!N0_A5$UNIFW=l5k*4~P@17QTz~M*z-G(RGCkhXKaxiEGY3J21C0sRwFeW?7jy z=d`v#brajCXMxZbHOtj?$9ixdqH%do}CCi{4mpkaA<*RSm=6lmUN)fAoe-j z{^yHDn-JPC{pg`r*UsYTLk^T_JJWXswxyoZ=6B-J2mU3htGBkOhq4*d#p!4xJ|WmR zE3WT!Y$o;K(838gFI91(&Ftil9Gfxe^_NA*vrY@t2dBy^e)Q10TNlrA4&TFfe)#_I z7WFW1?i|rBV1GKk#d%KGXG#waEu4aLt_P&cNLda8)*U{2x>KM&IHd)ee)PcnAyK*i zSoBA~{?ZFA>Y<{nRGe`_n=s0IuVz9I4lQtgI_L9iE#yELuzq+1UTEihluTF3z%sPqWy1c=w~x-D?KlFB3e=%IV(S5bdx zZPpuX^#{yBdhnlji!GZsCDg;-y?e!zk3W(ypHXg0!mLcE9voWWTGYCXltWseCYV1w zb<_2s)E)emm%jdTs_2R~rEX|bis2Q}qs!~!je-5e)X61c$Byj@wZNNmXU`HH&O9xQ zvJ&en8`7l*hZeZr^bCf-Ms9@x>(l?nS^;5=kMom1v`F?A{;+=CI&sI%H-_eR=%Fqh zdMLH;E&FR|V4~JRZs|b92la42#_)yF*6KHZXo2pYxA*MXBUp#N?Z&@`l`p71h&}1h z1HxL3!(hMj2o?6)%sowTJ(>G3-yt9LP+wm!rj$$+ogR5uoO}AoVde`;4~{LLf~&)# z1+M?X@!GnKl*3`b`~mAw=7%2Y>cl4_KNMG8@~3oYftqLvu7Kt@ShT=#st*HtJW>v8 zfoT={A0GSb)uHjrPyK=Ap2o&T(WKDSD0r=sE2+EpT@TV@z1@F|aC_3Sv)}HrE%Z1+JmJjA5;j`(eQThaOmS?8IhHC~l-=`q2Y< zAM1bk4f+paV4pN`?d6wd!xeo$ID*E#yfU zu>awm$Nw!(J?6+DJTjm4qlZ4-yC&>M`}C7h3H4C7bEmlFuh%4ehfkfn6I2ftEpUH@ zu38KEl@_o*{n-o81fD0Vco&u)_#TeqZs~ab$xd?J4fXv1CfA>NEQlT~T44Tg15l^+ zkY9RWd~t5vRp+%2r7Z484;G&MCPrGZ(B&Tj!X5u{ROWU6jsINMv z2dq~)H$CfBu3EWL)}c?udIS6xI2;rWct=NS!LJ?=hAMA35cavwFxBVzomX%y(~Gy= z9Ee}!DEo}3PAU{k!slbfE6+bG{(Ry2QU@Vvfqvya5C>2T zSbHkz`a^kdQwtoc_mbv*eskX~{+LIMfBAXwr!!9tlt-L|eMq_1ea8#V6@TiGB8+E$ ze{MT*_Nga{wnrZ!`x!zWuha+~TuUtweh!46${RQbdV^bd>TCJ05#|xE+;MB*`T0!0 z%|15O*OsCgVqW0P`^G^Ba@|%xda!7r73@Pb@AIGcUUA4gVlwuQyQafAVYS^jIhZTw z+n|99r~^M*U>;)8!|!3Q^$JUUy)bmbJmRfKABwH#8cv-xS2SRCM;GW|U%z^=XyN;? zkI|ej49>R{kK7yOi5sr9@~4pO)1iZu_GP!|;WF6kTM9>gzcBP7%S~v1z47-Q0@ba- zyIj-J2Ds^5l`r^dQ?h7*`#q1PaLXM^3pD*47Cl@7 zyUrU5r0*?RJ@9|j1m_EJ|J|_Wlx9O8aQiOg1y5t|)BTkmd}*P%zk&PqeC#I-^Y*_e zGqc>xvO;;^o`s7(J>|GV`ZbxjVt5R~8n5S~pDo`s0S(A8D7SU|!~E|5yz3VaU42Cn zG~q`I^1lct)vk0N>^qVPx4ixjxG`&BKd}<)ByPoA_HEenb=#_y0L?-I{Is%4Y{LGZ zi$5MI%E0wx;!SBHpYjRzTeL;r;3HUn5ObdDF<1Oj^+3{LmXH4}?b$^v|Kf8YuvT=`j(Y6BGw01gqAba>ld5C0 z-HE(TW8ZPg?NZc22h?|ZV%-o~{%z)O4fuHm;46<%|A+Y`P|#XX{@XM!J>xNRU0-{# zAK~JUKN6)qx+ERX)Wl@8#j<~bdoYGPrYL0{P@AY5h#M|CUwnZ5A(@}4__df_%X{}u zS`TkG0jNV7v)6>%rY6xPB0jlqS^wU|tDCX1&QR3$-2)-(DQ@ye{(!@Q-5v zS+8IjDpR_UI=~pgcGyetUs#iQ)HB6Wrylu&^21*E^ESYb9`?ikhoure`TC63L-OS% zXB?A%)CI?ibN<9-m{Y`d9hRfQu3xbIRK54Sw#Uo0$=~brnBaWZ1bKqef-*mYAJ~tO zuujeU2DlIIT0lKt@TS&7>UvN-QM%wfJI+1jeC!UVoG91rP-DOS>O+-i z{@LRBzuzQAKlhX@v-!}0;_+6vxh;?|k5Knv-UFy-BVlSi1lNP&jMN3@V>8X>x*c50 zhU+_V%`>LyTpN??#j;#QosjDaQa8A+`q5Yx;$FbqANwWj89xH`WUik^4Wyt0E0;Sd zzI$a7#(m}^%ulS(7Q9{SAv`@;xudi}lRW0ynC!#g8p^Eua;<&tKfyg&xK9!H?s^jY z*>R6Y?t99;fG55FQmi}#jRn+zmB(Mgod*D}a^Q6X>~q)zSf4F;yVgT`^x))?(g)WS z=b9emH?MJxKxs1F$93m@=X*jp-;Q^9_C?^H(nI9RN(@`Ahs@|91RjT(Hg{6IcV-gC z^>4%cD979!rvE+PrS%X=Jxm1GInT`|hq7K>Ro-w2?yb^#NTp}X|1-NA8blq|Ev>0q zF1D{;B^tKXh&|{p&g7A5e_VlZg!O_pfUTWLox@}wTqM_G%~bxd8+)a3-0Z5Ulg0cI z!^NyY{X{v-(fxYKaYTzh`ADpw)8h zs+TSii$;AYxzF)Ja{hsJ93AX(91+afLk5TylP1P=k;V)|>!I6Q;Ptnt0d>$0uO9X#*bGoTPo%a9~#RG9APW-2VAS@-y2%#8J}P9|Ih@>Q_IJX6Ad-hVY$qFkJf{n z>vpC^2TBju;~K|0X+4;1N!`+fy~9^e`%-Y8k>b4yTb`5uEIVzSGfUL2TPy1nYo>iA zD&Ku87XJ8;`aexRQ~B=OvdpzN#CaN850dZfV^BI!W*2MtN6j8){ek=@*JT)5cuh_- zt>-=F8+%Zzim z`^kOVdvVS_hMKBnn3|pX-0Y@?dax)dKCjw(83%qOrEtn~Xm1>|niwYs4^qEZXDu zD&Khv>w_#3%lA&J#5j#t6C5K=8LlI&N93++{n#&!-k^1yd+Sb;Kk{t`g*Z; z#y4^G3aLT#akI|RxHEYTYCV{G7*G~lgO6kQMmpm)YUj!34{Q@+nVn^Ka?lJX*jaa| zn((R}x|Eb-u-b%BK**Pw1^ZZJT1srU58}lpF zwU>?^gSz$>Pxoj&n0nw?*OP#=ff}GW?~kC}OAn1ZcglLPSDl&lY4$~D``Ed0Lu@@( zN8C}r?AzP2XrXIeTkFB(eH}Ewb?mPLBEOHcn;sI?ndc6DBi5c1pdB@A4Y97h`s**< z+q-04ds19oTkFBp!yf2><2KmN6xnf0@Vm?xt$Hx)%r5Qy{Qr_MAIo_>Ve>hR_sOyy z+FhyFxUwDciUp3kw$_8G2lC)0AkyQOOg$LuRkB};{aY-1JL{Y4?0l(q(*FYZRBsMtXHYtS77Gb@K2=H;d6~YaDVgsd2;NiTb-F4;&smF$)`2I zsaxtdZj3Gbqw3mqn>UH2pN>v`UWSpMx`t!^-}@Z?`l3MnW~PZP`VV@T|K7W>K1ba%cI8=PXyTZOWP#wisUd&wDR!RKV}$5%zy`xPTkha3EJW zwsc+DjHJe+=Cmf@IQE^e?N#mvWc4fU*7L&Ux-EXKac!|LgMG2(Jeg+-{y(yOg!Gi_ zUKG-?yK$FTTRL5ix78Z3)uR=sInFgw>lU}DeWaBOKl&hN+m8b46XhrFb>TWkBieZb zHBk#}X^|sQ2`6ZQZ8E!dY!6JkTz|o~6;*Pr0%x5f;#}Z z9{|_7*XxmKJ>)Z&!*DytE&UnzEwBOzjG%t|A760549frkLT0*LqVmX6QF_b1KcC%6|D!Yhlq2ae?_mZYQL;o zBOVqh^~dS_p`h}I2cZF_hjy?>dp|{E{Z>C6S~=POfc^wuhwgu1U6cDM{?Vd`*03)J zcrTLR`mKHs?w>p7%|SWSAD2r1G}i-P`q`MMk8j-o-X965{NcN>M|K@o&ZXzNzkUyn zU*Z}yxiWWvYi0O7xaKkUp^Nx>0@K0&A6xW5UAzuNGH)QCxegikLhyTVU+Aol*MOd9 zVI8HqEt~y1Up~Y2@3ZcQ>~e1qxWA7>58Nk+eb&*_1o*^pd8w>L#xl{`X~ z_4YJ1`gS|_o8!J05wAPU0q1{84XA^51CiZ3i=0^S!F$;2A%(rLkVkMI!;F`iSZ{z1 zcO<#@x>GRQSFgg{joy(=|G;#(0gst5N9j(FZcezPyyEGtnHrOo}+2jgC3$GVamtG=9^D!t^M?5vk( zgkx~)@-M~-?s1aJLH*7fHZ(JC&E52sV_I2nw&{VIFs?;=Y#%tr{nU1CPksM~Z7Wx# ze_sW-lYMC9bgIXeh4W*3sOBzLGf_5SpWFAyednPx0+Ve|nQ%+aeq+O|nW@rDKF9L> zyy5SJpY}or+!vJn&`ou9DZ7#TYcKwIB;1$5vR!U+>T${Y-U;=<{Db}1Im_2{x{YNW zwz&jJdr4G|_2YbIw&R9TequY)>^BCA4YMi)`^AGC)ytNOioU%f8NU|pY=iwYAg-*W z@(SbLcVLbNA{}1jUC@KP$C+iwH+Ubo3y7{Bz{9yi-;`;5U;^R=P4#tR^Md(e@#s;pehP#w zc`ow}d`pHkO@sfuSKhma>@#06X`T)%A7MzL|uEScVO9398U&KlScV?O%Jx;)1Ru9`YoY+bTAP`%zw z3k}<9urK@Dp_Je9oEu!zo^z!=%StM#$XzjN)qTYwhx28u-e4<^TSQ~;6H1N}}-u-qS{eKXry+P-RK&a5Mt z1AJNpH?#(l(gQWYI)?H8Cvw33IiW|EHP~*SZ^us5S9G1hmZrb1_)V@Sb13b=JQ`T5 zHG%L%KiJAClk!Rvb(=Tqxo`IJ%~o*vR3PcRLFvVKhU5MQ06CFHXWbpp#EMB1L{me3 z&Sw*~SchMa<&NX+`{2;GfugdWT@=-mGUYQd9i0t3=S*l#7-6vVv(bWWm$`aWFJCJA zF2d*+FQ9m@hhI7ZzVikOMm-0U?NX6nzYk$xrU&F1)81_>{m-XMr-pX#+9lSN%@8vO z_SJn1miRK_j_dFJ5GX1KQaSjn0j^EM^^Z+XMBPqJkaue^r;hyw**EZeSDdi1HJL-9uT-S08pu^c31_tk#S8zNA)8|Yd95=|hdFO#E(Cd*zrkFe^&u#<=7eR_z6AANvzFXyMfuL6DB^;@=xHPffbdV(Ay zsPhD`w3>Ot^@_(Ttxwfd?}5pA6QcmH??(Q*)CJn0IQL;KxXN_~c8Az!(YUi#aIALK zgwN&JZ>@u*xL5+tnrn@vssrnDc%RBAI>4^mnp#K`s0(7|fWC50HR~8$52hab&B-zq z()X4Qu8qb%agOokm{iUI;@A=9L%zq2x8U9aPU9NjH?9c~xSZsn2hJybqkzLN^gK>! zgFK!+WT2c2v25IDDDzJh8)lYEu5&LIma{mj3As^?6HSV&ofg?t{nNAgLeDjZpI=T6(9x@5Q@naFl&# zq2>)rNb0?1Jv;OL&jMcV(ARa?QX!GZPQV!}0KOcIA^F|?pGaVO3r zyXLD-3vwnc{~!a;W!_zBE$KqkXOe=EA-{S~QdL6Mi4;tm|m5>M#hC1KcCM7)a|_y|DBJcdERi73`gXFvCK>ONZw`aEg6i zl4oJ@Hl6QLZiV?{;9WrTScgFv9N^fbp8-+}2OV@!I%yo*yUTlbRe1!>Ujn+#N$Vk~ z9@v(2u+jqd(F={QnRtk`d^)E;D*E(P<);&HJ{#~89{Ru04KL1fJBu7r$S3Z5xmEea zFi(Q1H4$BoC!4-e3+=7k@#0rLUbp%ScIFeh?4&i}r45#m3NBx8>eitN)+clwTWi9p ziJ9QV$yTm7c~nT}^e5LFH~h8n|3ImRJ z;uyfKfYw4Z!e9sD`U$`skHq!8qI}rKcEr{?y9D8*=T8*A9#$fruLHPer%V&e(ch+z zSO};vG0ZkFbv?UqHNpLQ%zbYe=lX~b0YeL_{KUDZuK_y&U4ANzFlazr_5s+2qT)H8 z2>OZ{0@iSXoof_a3+VDw{_9}{;_+61^+c@&lkqKE=JaR|FC*-@zSDa^6QJ{o{0alk zXXCsDj!7_eP?}@&om*yQ_WxlGKiJs@aW_x}Xielt7_3EHJp>#8m|Dm_v+~yk)^IfJ z90$+-J3Ti$cQwI$d=y|CcY*W#C1Q@boMCAJ*V?}x(BoutPZJ!AcN_3Sz>KpTqfm0qpP8y2xx8 z)FI5@2hIe{zBSm>O9K6NTL^sB|2110gz*11a3Sz9P!DKbq&Ey$fBPKxGr;nz86Pur zKeLUinLH`8kAY&GaKB-W`S=Xbx(Htr)WJmHYT!qJsRP>eksBf4j2E2$0O$yO1Z)R% zei1ATI8K0R%$0zx{S@c9K?3cHxDa66IPe{up9>5E)&W`<{=#4r!fYsT5x_nmTlmIB zfWDBEA>fQRoO8WT&b=xIW&*me+glf$lQ0i>0XPxZAJ96;Nv8XAlrtWU^Pj=|3-B($ zK2)7&#KK?$!s0{VufQ(>n+ABUkHQH7m-yv)!6SitfKP#qK-9FseuFQ72LSf{vz^YS zgIWFbyoAD0LA+0#@ok*5&5(ScHdqH=1+d&uX!_vzI_8OE0BV5cp}2Yh+wJrby$}Ew zY-yo!&NPz!tXBcufp37#z+NC9^-&FXmjQhMmW2)n*v`=G_pUF(o?mMOM~|(U^|6f~ zR<6X_7ddC|2B0f25#V@!_RZ#+KG^5Nylo256Sx^*9`N5Y`^Eb7VUOt`Ha|DBfvmrl zmm$D;?Iv#&f0|A9`5gqD4crdA4sbkYIY2IM1Jb!x2;0Wkr?Li^1#o@=b$SZRkW{p&-9)7 zpXPg}@63Dqn9tGTILvz`jvF{N?-j@2yBC)jxA9IXDpL32LIuyXF`kQeX7jx&{Kt9x z7#H}x66Xz^t9!)>?z!BOmpr_n?zwqE-E;GTx>qADECx!6z9^RSgo~PIGT$RVy7`G)U-YZV+5E^`1x6%5*o?dsXh?DbwVz@3q0VN<6-oj(b1Gw~9T!myLTK zPSKZj2Rwy z=w7PjiA2wN6fs*Y_n`LdcEswGiSCuSJysN}rzW~r<@#JF#;Ztl&pqO0k=3KTo8aC) zv3stOWYpgi-E)ly)h6KPzXM|5bB!c*uh`{5)do@Ga<857&P4aBT%J>H9&Q0DjCZ=Q z-fTy437ACp_Eqn63A9$~o=dm>Ry}&;XU(SvBROQE%!hNk76w)%ZL$33fKUA#rjT56vjShdCu~|*ypVGOdrO4XM3mV! + + + + + + + + + + + + + + + + + diff --git a/internal/frontend/share/icons/macos_gray.png b/internal/frontend/share/icons/macos_gray.png new file mode 100644 index 0000000000000000000000000000000000000000..d941f37ec0b38c98ddd53c1b1e27d71d420690e5 GIT binary patch literal 849 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 literal 0 HcmV?d00001 diff --git a/internal/frontend/share/icons/macos_red_hl.png b/internal/frontend/share/icons/macos_red_hl.png new file mode 100644 index 0000000000000000000000000000000000000000..fbaf845b462e9815dde2b8063cdbd3ff71384fdf GIT binary patch literal 1452 zcmV;d1ylNoP)c{%sF$;Sw2js z&diyih46dad!7BQwf5d?ucKI?ZXVvzruZ-trHQ)9B^g{3(o$V{M=u;pG zfQV^QW17@PA{bf+>^;vTW7}A^P1d#xtDg^Pb$sRN{<{l?-#Qp?_ch-j#3JD^4Rv*d z7cF{L5GBfa9#5yH$YiqsDW%lWRbBmq^TLM@#XkYQQc8uIqV?29Y8DJFSH`j!pPWQQ zTu~fb)0OLc)PDSr?n2X#A2Aw5p6Zuxg4YEG`{bCrPI!02m)j(bqi?aKLdK`g;Z$ z9T@}Q>BJ=Gx|0D18tUq1ae1D)d3Z;g@%inyRZ9BJ=jE$tzWNll-aF=QA8V3MX8!-bPzqfS*txK8%{8td!PWtGb?3|@QVRUSY5m&{D2WX{T1 z{Qc)`W~OH7$S^chGZ|1~Yk_52X$->%7y^XNFo}*`w6q2@;QM|s;^s!; z`*spG!xh&vw1H9zM7^RFv}q$)t4kaRS9hQsynBD;h>I1DjJTz>nLQnG z>gpF0|FH5c7#~e>ZRockJkJr|rB~;hJ#{r}+c-J5#BlxQb;KGQN+T}2$JOdUG!|w3 zrguxmayd5-7lzjM3_Q{R0w(|_rl(4bYic6w-XEWHpxQkyuLIFol)d}o)YO)~km>13 z0DMEAfTCt%xHEnZVNY{3N^MQqvzp0r{pa7=v~@$($_4qu`OB5-Y}mYxn%YRo+>DiF z{OJTJ&aUoC?kj=+bvUuy_2e3;#+Tp86oawvxthMKVOAAgM z-qD7;XrI!4yAqZOs*TydRE0bg#RCzNzDw`go`FXTS@S;yA#!{2G8?S`0000jk;;F7dg-ZBRD#qVp)C@2AO|?~4y$<_rV+-3tvFc)+*fDkre>F)bq!y+Q!0zf_n;#|)??&kj?9pG-6sC0 zcI?>syGPH{JRmc4eDMASyr?yB zO5HhoF0Fx1B9r3Mk@4YdPr(40A&;@)0ASQJ{;kw*09Nl5i@=3^)<0H`U#Am+PmuLc z^y2Db>3j#S%1wabli9w2Q;ocp1Hh5+WNo21;e1&Sa=x4z4f+ZP*xC*WTd|s0Cyn*w zGeGh=zwcmxe9i+9_01(ChEif26g;UZ#Z4QONh8TwJqUT8V2l9rjsYePI;;D-fMlbm z4KQY8u<$+Jm;q`|^$7Rg$~gt0W`Ip=-9X{iDJahk!n)Hv!yv#`Ng}Pk8BwVKG@9{1 z;2n<010%7!3uq%86g&b@5p&O+x&ClN;YrwvX-BrN;C5sQTNV)>7p_(|4S<1pfIt3f z_Z6PbuU6*wxKNl`dZchaY{l#*eOJ%*Pa{ej%O5|OtuA-q0P5@vBO1+^u(g*mSw{P#-T09{AqfrzBgztrctX`OW_0SWq#ot?Z@HvG#|PraIP z$z>=SY`~~JU||E^%yd&FiC}rvGgMtJWzmN zINpBPtN#_ChNk+;fC~#q1PhBXKv>`)dbaP(Z+`(r(F@2qC>rW_c)sPx28_A`A)shd z??&_O3;-7g+F$Xa&sod6I^zdvUyXsa3>E1-ccS&>0zC0D41j9)+p2)`s>{t!M(H#F z&~WdOmQ*AC9a`q5`lzaK09aUx;j-#y=aUwGq=C8!;3%w)yGs?>03bD~RM5p_xB~!c zY_KQrnkW~{1brdMCcZgf0+(!jYT{3b$R@*gCT2!kM{Ch{?sg}l&4B_3joEm$GOKaL z;H5H#kvK^)6V=hSxvjDy0*KJSB3M%0TezwMs_B49XT6t0r3N+oqpG>xdj$w{fep+* zBJ!k&uFKQKK~hP1pL26yc!ovf8USRnzACU%#V*GYuj4$sko`shiT4Vg^?;I}oA+0E zaO~zCCYp3k;F8^+@q^?NfpY?4{bTUbjk^p0#5@E*S5{qB;rHj)kZ1_FpVj(Cu3sg> zgmZ$mxH|4%%Or44U=01-^U3s21vmgef_@Ov%lBNq`mC5-B3QoXK!o`~@NT}n06%ti z@>Ud3x2n4IO15GPaETzjlq({M`K_K$r*G%`)}@p0fBEAgHEN-2WY16U-&oH0Bj lpz*o?+?BygH|~}*=l_X=!=F5AfSdpT002ovPDHLkV1g~r5+MKp literal 0 HcmV?d00001 diff --git a/internal/frontend/share/icons/macos_yellow_hl.png b/internal/frontend/share/icons/macos_yellow_hl.png new file mode 100644 index 0000000000000000000000000000000000000000..4b8a44061d68e248f14a92c107cb371b3eec3271 GIT binary patch literal 1041 zcmV+s1n&EZP)yQk?4O=ap8m%l~jUb3l!B(n?rlx4``G~6$hfE4RS&##A|zZ=5g4p zaowbG8@u1z&d$td_Pw|7y%E~f+QQVB`sSR97Es24QD6ip1DgozqEeLD-8^~u?nXO% z%25*@eqYENv0sCHn9esw@Qh$YaH?3RIH&0nRd`@veqv_n-YdaZ77OnP?i=6?K#=vx zWqooPAJ2P!nRQB~8dC{l!djf10&c6bbCa{nkJ^T>+{hP11p7F1x;|5^$#?%yWCYSM#<>MErFYpO6 z9*Uk_UCf_uz*V^lFwmdr3OLo^8(9Dx2~XA*rpBBv>p|9+6GK5);Q(9PAz>|66YIFK zo_r2SF6(z443Nuu0HVG*XT*?C245FEi7CZR`zYf^l3d-aU#}NY)q~c*lthG2W($pH z&l8LhoZ~GY7&|Ce?>()cbO3%p2KS8MoQ@h`*vOH^t)trK4I7~BI$++Hk#h<{*#|bQ zb)&5l`ucMn^LeYRQ&1lJ2w-rBDq?P#lb7#qC_D&jG4;rH6hYNh1(0!IjQ?+`8=&im+z~OiCN35?+u8F!AsL`ySWM=^00000 LNkvXXu0mjfF8tk; literal 0 HcmV?d00001 diff --git a/internal/frontend/share/icons/pm_logo.png b/internal/frontend/share/icons/pm_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d1e5143054758ff4fc1758e77ce160e6f67ef31a GIT binary patch literal 5442 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 R%kX`I>bh!$7jOLi{{V@BP+0%~ literal 0 HcmV?d00001 diff --git a/internal/frontend/share/icons/rectangle-systray.png b/internal/frontend/share/icons/rectangle-systray.png new file mode 100644 index 0000000000000000000000000000000000000000..39cd4f9b9e2a1bfd179ae781e978063a5a0da6a0 GIT binary patch literal 23551 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 literal 0 HcmV?d00001 diff --git a/internal/frontend/share/icons/rectangle-syswarn.png b/internal/frontend/share/icons/rectangle-syswarn.png new file mode 100644 index 0000000000000000000000000000000000000000..77b1538550f95d975bf2cc39baaeb7412ede345f GIT binary patch literal 33799 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 CskL7K literal 0 HcmV?d00001 diff --git a/internal/frontend/share/icons/rounded-app.png b/internal/frontend/share/icons/rounded-app.png new file mode 100644 index 0000000000000000000000000000000000000000..99222ac16880a8834d6c39443d28ddab2c8c5fed GIT binary patch literal 25720 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 literal 0 HcmV?d00001 diff --git a/internal/frontend/share/icons/rounded-app.svg b/internal/frontend/share/icons/rounded-app.svg new file mode 100644 index 00000000..9825d2d3 --- /dev/null +++ b/internal/frontend/share/icons/rounded-app.svg @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + diff --git a/internal/frontend/share/icons/rounded-systray.png b/internal/frontend/share/icons/rounded-systray.png new file mode 100644 index 0000000000000000000000000000000000000000..c488be35b921698274cb2145f3686e2a6b1a90dc GIT binary patch literal 25785 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 E0F~H19{>OV literal 0 HcmV?d00001 diff --git a/internal/frontend/share/icons/rounded-syswarn.png b/internal/frontend/share/icons/rounded-syswarn.png new file mode 100644 index 0000000000000000000000000000000000000000..6c49d57834df284b817d36df3ac56c899590fb5c GIT binary patch literal 35766 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%0D-KuzJ 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∾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 UegntyPN6$vd%}0u?Tq{GZ`lb~6951J literal 0 HcmV?d00001 diff --git a/internal/frontend/share/icons/white-syswarn.png b/internal/frontend/share/icons/white-syswarn.png new file mode 100644 index 0000000000000000000000000000000000000000..7f4fe073f0b7d138727005b258e2e52b4c6367e7 GIT binary patch literal 42850 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 literal 0 HcmV?d00001 diff --git a/internal/frontend/share/icons/win10_Dash.png b/internal/frontend/share/icons/win10_Dash.png new file mode 100644 index 0000000000000000000000000000000000000000..019d5a998808056ed65d1b69127a775f13c38edc GIT binary patch literal 147 zcmeAS@N?(olHy`uVBq!ia0vp^0zk~j!3HF+SL($BDVAa<&kznEsNqQI0P;BtJR*x3 z82FBWFymBhK53w!WQl7;NpOBzNqJ&XDnogBxn5>oc5!lIL8@MUQTpt6Hc~)E0-i38 lAsjQ4|NQ^|zn%?f>CX=4e-m`ezXRnMJYD@<);T3K0RR5r00004b3#c}2nYxW zdL{vguWJF!CRc;X_fmRzTgl8 z4SFx$;}oNv`t{=gM|gxje8g7_HSd=SlinmBl1wG}TibhGO8M6|y~*S;e&D{cbDIlB zl6+2bmgIMmS4kF1DRw3w~eKF>@QJc%o8wh36pEuJ?I zX7LkiT~u7gFTAJ=bGXAwC%D065r41{>|?20xWVKl_WlDn>KJhvN>ir*0000. + +// Package types provides interfaces used in frontend packages. +package types + +import ( + "github.com/ProtonMail/proton-bridge/internal/bridge" + pmapi "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/ProtonMail/proton-bridge/pkg/updates" +) + +// PanicHandler is an interface of a type that can be used to gracefully handle panics which occur. +type PanicHandler interface { + HandlePanic() +} + +// Updater is an interface for handling Bridge upgrades. +type Updater interface { + CheckIsBridgeUpToDate() (isUpToDate bool, latestVersion updates.VersionInfo, err error) + GetDownloadLink() string + GetLocalVersion() updates.VersionInfo + StartUpgrade(currentStatus chan<- updates.Progress) +} + +type NoEncConfirmator interface { + ConfirmNoEncryption(string, bool) +} + +// Bridger is an interface of bridge needed by frontend. +type Bridger interface { + GetCurrentClient() string + SetCurrentOS(os string) + Login(username, password string) (bridge.PMAPIProvider, *pmapi.Auth, error) + FinishLogin(client bridge.PMAPIProvider, auth *pmapi.Auth, mailboxPassword string) (BridgeUser, error) + GetUsers() []BridgeUser + GetUser(query string) (BridgeUser, error) + DeleteUser(userID string, clearCache bool) error + ReportBug(osType, osVersion, description, accountName, address, emailClient string) error + ClearData() error +} + +// BridgeUser is an interface of user needed by frontend. +type BridgeUser interface { + ID() string + Username() string + IsConnected() bool + IsCombinedAddressMode() bool + GetPrimaryAddress() string + GetAddresses() []string + GetBridgePassword() string + SwitchAddressMode() error + Logout() error +} + +type bridgeWrap struct { + *bridge.Bridge +} + +// NewBridgeWrap wraps bridge struct into local bridgeWrap to implement local interface. +// The problem is that Bridge returns the bridge package's User type. +// Every method which returns User therefore has to be overridden to fulfill the interface. +func NewBridgeWrap(bridge *bridge.Bridge) *bridgeWrap { //nolint[golint] + return &bridgeWrap{Bridge: bridge} +} + +func (b *bridgeWrap) FinishLogin(client bridge.PMAPIProvider, auth *pmapi.Auth, mailboxPassword string) (BridgeUser, error) { + return b.Bridge.FinishLogin(client, auth, mailboxPassword) +} + +func (b *bridgeWrap) GetUsers() (users []BridgeUser) { + for _, user := range b.Bridge.GetUsers() { + users = append(users, user) + } + return +} + +func (b *bridgeWrap) GetUser(query string) (BridgeUser, error) { + return b.Bridge.GetUser(query) +} diff --git a/internal/imap/backend.go b/internal/imap/backend.go new file mode 100644 index 00000000..06731928 --- /dev/null +++ b/internal/imap/backend.go @@ -0,0 +1,220 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Package imap provides IMAP server of the Bridge. +package imap + +import ( + "strings" + "sync" + "time" + + imapid "github.com/ProtonMail/go-imap-id" + "github.com/ProtonMail/proton-bridge/internal/bridge" + "github.com/ProtonMail/proton-bridge/internal/events" + "github.com/ProtonMail/proton-bridge/pkg/listener" + goIMAPBackend "github.com/emersion/go-imap/backend" +) + +type panicHandler interface { + HandlePanic() +} + +type imapBackend struct { + panicHandler panicHandler + bridge bridger + updates chan interface{} + eventListener listener.Listener + + users map[string]*imapUser + usersLocker sync.Locker + + lastMailClient imapid.ID + lastMailClientLocker sync.Locker + + imapCache map[string]map[string]string + imapCachePath string + imapCacheLock *sync.RWMutex +} + +// NewIMAPBackend returns struct implementing go-imap/backend interface. +func NewIMAPBackend( + panicHandler panicHandler, + eventListener listener.Listener, + cfg configProvider, + bridge *bridge.Bridge, +) *imapBackend { //nolint[golint] + bridgeWrap := newBridgeWrap(bridge) + backend := newIMAPBackend(panicHandler, cfg, bridgeWrap, eventListener) + + // We want idle updates coming from bridge's updates channel (which in turn come + // from the bridge users' stores) to be sent to the imap backend's update channel. + backend.updates = bridge.GetIMAPUpdatesChannel() + + go backend.monitorDisconnectedUsers() + + return backend +} + +func newIMAPBackend( + panicHandler panicHandler, + cfg configProvider, + bridge bridger, + eventListener listener.Listener, +) *imapBackend { + return &imapBackend{ + panicHandler: panicHandler, + bridge: bridge, + updates: make(chan interface{}), + eventListener: eventListener, + + users: map[string]*imapUser{}, + usersLocker: &sync.Mutex{}, + + lastMailClient: imapid.ID{imapid.FieldName: clientNone}, + lastMailClientLocker: &sync.Mutex{}, + + imapCachePath: cfg.GetIMAPCachePath(), + imapCacheLock: &sync.RWMutex{}, + } +} + +func (ib *imapBackend) getUser(address string) (*imapUser, error) { + ib.usersLocker.Lock() + defer ib.usersLocker.Unlock() + + address = strings.ToLower(address) + imapUser, ok := ib.users[address] + if ok { + return imapUser, nil + } + return ib.createUser(address) +} + +// createUser require that address MUST be in lowercase. +func (ib *imapBackend) createUser(address string) (*imapUser, error) { + log.WithField("address", address).Debug("Creating new IMAP user") + + user, err := ib.bridge.GetUser(address) + if err != nil { + return nil, err + } + + // Make sure you return the same user for all valid addresses when in combined mode. + if user.IsCombinedAddressMode() { + address = strings.ToLower(user.GetPrimaryAddress()) + if combinedUser, ok := ib.users[address]; ok { + return combinedUser, nil + } + } + + // Client can log in only using address so we can properly close all IMAP connections. + var addressID string + if addressID, err = user.GetAddressID(address); err != nil { + return nil, err + } + + newUser, err := newIMAPUser(ib.panicHandler, ib, user, addressID, address) + if err != nil { + return nil, err + } + + ib.users[address] = newUser + + return newUser, nil +} + +// deleteUser removes a user from the users map. +// This is a safe operation even if the user doesn't exist so it is no problem if it is done twice. +func (ib *imapBackend) deleteUser(address string) { + log.WithField("address", address).Debug("Deleting IMAP user") + + ib.usersLocker.Lock() + defer ib.usersLocker.Unlock() + + delete(ib.users, strings.ToLower(address)) +} + +// Login authenticates a user. +func (ib *imapBackend) Login(username, password string) (goIMAPBackend.User, error) { + // Called from go-imap in goroutines - we need to handle panics for each function. + defer ib.panicHandler.HandlePanic() + + imapUser, err := ib.getUser(username) + if err != nil { + log.WithError(err).Warn("Cannot get user") + return nil, err + } + + if err := imapUser.user.CheckBridgeLogin(password); err != nil { + log.WithError(err).Error("Could not check bridge password") + _ = imapUser.Logout() + // Apple Mail sometimes generates a lot of requests very quickly. + // It's therefore good to have a timeout after a bad login so that we can slow + // those requests down a little bit. + time.Sleep(10 * time.Second) + return nil, err + } + + // The update channel should be nil until we try to login to IMAP for the first time + // so that it doesn't make bridge slow for users who are only using bridge for SMTP + // (otherwise the store will be locked for 1 sec per email during synchronization). + imapUser.user.SetIMAPIdleUpdateChannel() + + return imapUser, nil +} + +// Updates returns a channel of updates for IMAP IDLE extension. +func (ib *imapBackend) Updates() <-chan interface{} { + // Called from go-imap in goroutines - we need to handle panics for each function. + defer ib.panicHandler.HandlePanic() + + return ib.updates +} + +func (ib *imapBackend) CreateMessageLimit() *uint32 { + return nil +} + +func (ib *imapBackend) setLastMailClient(id imapid.ID) { + ib.lastMailClientLocker.Lock() + defer ib.lastMailClientLocker.Unlock() + + if name, ok := id[imapid.FieldName]; ok && ib.lastMailClient[imapid.FieldName] != name { + ib.lastMailClient = imapid.ID{} + for k, v := range id { + ib.lastMailClient[k] = v + } + log.Warn("Mail Client ID changed to ", ib.lastMailClient) + ib.bridge.SetCurrentClient( + ib.lastMailClient[imapid.FieldName], + ib.lastMailClient[imapid.FieldVersion], + ) + } +} + +// monitorDisconnectedUsers removes users when it receives a close connection event for them. +func (ib *imapBackend) monitorDisconnectedUsers() { + ch := make(chan string) + ib.eventListener.Add(events.CloseConnectionEvent, ch) + + for address := range ch { + // delete the user to ensure future imap login attempts use the latest bridge user + // (bridge user might be removed-readded so we want to use the new bridge user object). + ib.deleteUser(address) + } +} diff --git a/internal/imap/backend_cache.go b/internal/imap/backend_cache.go new file mode 100644 index 00000000..ff4dd6fd --- /dev/null +++ b/internal/imap/backend_cache.go @@ -0,0 +1,136 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package imap + +import ( + "encoding/json" + "errors" + "os" + "strings" +) + +// Cache keys. +const ( + SubscriptionException = "subscription_exceptions" +) + +// addToCache adds item to existing item list. +// Starting from following structure: +// { +// "username": {"label": "item1;item2"} +// } +// +// After calling addToCache("username", "label", "newItem") we get: +// { +// "username": {"label": "item1;item2;newItem"} +// } +// +func (ib *imapBackend) addToCache(userID, label, toAdd string) { + list := ib.getCacheList(userID, label) + + if list != "" { + list = list + ";" + toAdd + } else { + list = toAdd + } + + ib.imapCacheLock.Lock() + ib.imapCache[userID][label] = list + ib.imapCacheLock.Unlock() + + if err := ib.saveIMAPCache(); err != nil { + log.Info("Backend/userinfo: could not save cache: ", err) + } +} + +func (ib *imapBackend) removeFromCache(userID, label, toRemove string) { + list := ib.getCacheList(userID, label) + + split := strings.Split(list, ";") + + for i, item := range split { + if item == toRemove { + split = append(split[:i], split[i+1:]...) + } + } + + ib.imapCacheLock.Lock() + ib.imapCache[userID][label] = strings.Join(split, ";") + ib.imapCacheLock.Unlock() + + if err := ib.saveIMAPCache(); err != nil { + log.Info("Backend/userinfo: could not save cache: ", err) + } +} + +func (ib *imapBackend) getCacheList(userID, label string) (list string) { + if err := ib.loadIMAPCache(); err != nil { + log.Warn("Could not load cache: ", err) + } + + ib.imapCacheLock.Lock() + if ib.imapCache == nil { + ib.imapCache = map[string]map[string]string{} + } + + if ib.imapCache[userID] == nil { + ib.imapCache[userID] = map[string]string{} + ib.imapCache[userID][SubscriptionException] = "" + } + + list = ib.imapCache[userID][label] + + ib.imapCacheLock.Unlock() + + _ = ib.saveIMAPCache() + return +} + +func (ib *imapBackend) loadIMAPCache() error { + if ib.imapCache != nil { + return nil + } + + ib.imapCacheLock.Lock() + defer ib.imapCacheLock.Unlock() + + f, err := os.Open(ib.imapCachePath) + if err != nil { + return err + } + defer f.Close() //nolint[errcheck] + + return json.NewDecoder(f).Decode(&ib.imapCache) +} + +func (ib *imapBackend) saveIMAPCache() error { + if ib.imapCache == nil { + return errors.New("cannot save cache: cache is nil") + } + + ib.imapCacheLock.Lock() + defer ib.imapCacheLock.Unlock() + + f, err := os.Create(ib.imapCachePath) + if err != nil { + return err + } + defer f.Close() //nolint[errcheck] + + return json.NewEncoder(f).Encode(ib.imapCache) +} diff --git a/internal/imap/bridge.go b/internal/imap/bridge.go new file mode 100644 index 00000000..7e036596 --- /dev/null +++ b/internal/imap/bridge.go @@ -0,0 +1,78 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package imap + +import ( + "github.com/ProtonMail/proton-bridge/internal/bridge" +) + +type configProvider interface { + GetEventsPath() string + GetDBDir() string + GetIMAPCachePath() string +} + +type bridger interface { + SetCurrentClient(clientName, clientVersion string) + GetUser(query string) (bridgeUser, error) +} + +type bridgeUser interface { + ID() string + CheckBridgeLogin(password string) error + IsCombinedAddressMode() bool + GetAddressID(address string) (string, error) + GetPrimaryAddress() string + SetIMAPIdleUpdateChannel() + UpdateUser() error + Logout() error + CloseConnection(address string) + GetStore() storeUserProvider + GetTemporaryPMAPIClient() bridge.PMAPIProvider +} + +type bridgeWrap struct { + *bridge.Bridge +} + +// newBridgeWrap wraps bridge struct into local bridgeWrap to implement local +// interface. Problem is that bridge is returning package bridge's User type, +// so every method that returns User has to be overridden to fulfill the interface. +func newBridgeWrap(bridge *bridge.Bridge) *bridgeWrap { + return &bridgeWrap{Bridge: bridge} +} + +func (b *bridgeWrap) GetUser(query string) (bridgeUser, error) { + user, err := b.Bridge.GetUser(query) + if err != nil { + return nil, err + } + return newBridgeUserWrap(user), nil +} + +type bridgeUserWrap struct { + *bridge.User +} + +func newBridgeUserWrap(bridgeUser *bridge.User) *bridgeUserWrap { + return &bridgeUserWrap{User: bridgeUser} +} + +func (u *bridgeUserWrap) GetStore() storeUserProvider { + return newStoreUserWrap(u.User.GetStore()) +} diff --git a/internal/imap/cache/cache.go b/internal/imap/cache/cache.go new file mode 100644 index 00000000..2689c848 --- /dev/null +++ b/internal/imap/cache/cache.go @@ -0,0 +1,151 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package cache + +import ( + "bytes" + "sort" + "sync" + "time" + + backendMessage "github.com/ProtonMail/proton-bridge/pkg/message" +) + +type key struct { + ID string + Timestamp int64 + Size int +} + +type oldestFirst []key + +func (s oldestFirst) Len() int { return len(s) } +func (s oldestFirst) Swap(i, j int) { s[i], s[j] = s[j], s[i] } +func (s oldestFirst) Less(i, j int) bool { return s[i].Timestamp < s[j].Timestamp } + +type cachedMessage struct { + key + data []byte + structure backendMessage.BodyStructure +} + +//nolint[gochecknoglobals] +var ( + cacheTimeLimit = int64(1 * 60 * 60 * 1000) // milliseconds + cacheSizeLimit = 100 * 1000 * 1000 // B - MUST be larger than email max size limit (~ 25 MB) + mailCache = make(map[string]cachedMessage) + + // cacheMutex takes care of one single operation, whereas buildMutex takes + // care of the whole action doing multiple operations. buildMutex will protect + // you from asking server or decrypting or building the same message more + // than once. When first request to build the message comes, it will block + // all other build requests. When the first one is done, all others are + // handled by cache, not doing anything twice. With cacheMutex we are safe + // only to not mess up with the cache, but we could end up downloading and + // building message twice. + cacheMutex = &sync.Mutex{} + buildMutex = &sync.Mutex{} + buildLocks = map[string]interface{}{} +) + +func (m *cachedMessage) isValidOrDel() bool { + if m.key.Timestamp+cacheTimeLimit < timestamp() { + delete(mailCache, m.key.ID) + return false + } + return true +} + +func timestamp() int64 { + return time.Now().UnixNano() / int64(time.Millisecond) +} + +func Clear() { + mailCache = make(map[string]cachedMessage) +} + +// BuildLock locks per message level, not on global level. +// Multiple different messages can be building at once. +func BuildLock(messageID string) { + for { + buildMutex.Lock() + if _, ok := buildLocks[messageID]; ok { // if locked, wait + buildMutex.Unlock() + time.Sleep(10 * time.Millisecond) + } else { // if unlocked, lock it + buildLocks[messageID] = struct{}{} + buildMutex.Unlock() + return + } + } +} + +func BuildUnlock(messageID string) { + buildMutex.Lock() + defer buildMutex.Unlock() + delete(buildLocks, messageID) +} + +func LoadMail(mID string) (reader *bytes.Reader, structure *backendMessage.BodyStructure) { + reader = &bytes.Reader{} + cacheMutex.Lock() + defer cacheMutex.Unlock() + if message, ok := mailCache[mID]; ok && message.isValidOrDel() { + reader = bytes.NewReader(message.data) + structure = &message.structure + + // Update timestamp to keep emails which are used often. + message.Timestamp = timestamp() + } + return +} + +func SaveMail(mID string, msg []byte, structure *backendMessage.BodyStructure) { + cacheMutex.Lock() + defer cacheMutex.Unlock() + + newMessage := cachedMessage{ + key: key{ + ID: mID, + Timestamp: timestamp(), + Size: len(msg), + }, + data: msg, + structure: *structure, + } + + // Remove old and reduce size. + totalSize := 0 + messageList := []key{} + for _, message := range mailCache { + if message.isValidOrDel() { + messageList = append(messageList, message.key) + totalSize += message.key.Size + } + } + sort.Sort(oldestFirst(messageList)) + var oldest key + for totalSize+newMessage.key.Size >= cacheSizeLimit { + oldest, messageList = messageList[0], messageList[1:] + delete(mailCache, oldest.ID) + totalSize -= oldest.Size + } + + // Write new. + mailCache[mID] = newMessage +} diff --git a/internal/imap/cache/cache_test.go b/internal/imap/cache/cache_test.go new file mode 100644 index 00000000..1401d964 --- /dev/null +++ b/internal/imap/cache/cache_test.go @@ -0,0 +1,99 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package cache + +import ( + "bytes" + "fmt" + "testing" + "time" + + bckMsg "github.com/ProtonMail/proton-bridge/pkg/message" + "github.com/stretchr/testify/require" +) + +var bs = &bckMsg.BodyStructure{} //nolint[gochecknoglobals] +const testUID = "testmsg" + +func TestSaveAndLoad(t *testing.T) { + msg := []byte("Test message") + + SaveMail(testUID, msg, bs) + require.Equal(t, mailCache[testUID].data, msg) + + reader, _ := LoadMail(testUID) + require.Equal(t, reader.Len(), len(msg)) + stored := make([]byte, len(msg)) + _, _ = reader.Read(stored) + require.Equal(t, stored, msg) +} + +func TestMissing(t *testing.T) { + reader, _ := LoadMail("non-existing") + require.Equal(t, reader.Len(), 0) +} + +func TestClearOld(t *testing.T) { + cacheTimeLimit = 10 + msg := []byte("Test message") + SaveMail(testUID, msg, bs) + time.Sleep(100 * time.Millisecond) + + reader, _ := LoadMail(testUID) + require.Equal(t, reader.Len(), 0) +} + +func TestClearBig(t *testing.T) { + msg := []byte("Test message") + + nSize := 3 + cacheSizeLimit = nSize*len(msg) + 1 + cacheTimeLimit = int64(nSize * nSize * 2) // be sure the message will survive + + // It should have more than nSize items. + for i := 0; i < nSize*nSize; i++ { + time.Sleep(1 * time.Millisecond) + SaveMail(fmt.Sprintf("%s%d", testUID, i), msg, bs) + if len(mailCache) > nSize { + t.Error("Number of items in cache should not be more than", nSize) + } + } + + // Check that the oldest are deleted first. + for i := 0; i < nSize*nSize; i++ { + iUID := fmt.Sprintf("%s%d", testUID, i) + reader, _ := LoadMail(iUID) + if i < nSize*(nSize-1) && reader.Len() != 0 { + mail := mailCache[iUID] + t.Error("LoadMail should return empty but have:", mail.data, iUID, mail.key.Timestamp) + } + stored := make([]byte, len(msg)) + _, _ = reader.Read(stored) + + if i >= nSize*(nSize-1) && !bytes.Equal(stored, msg) { + t.Error("LoadMail returned wrong message:", stored, iUID) + } + } +} + +func TestConcurency(t *testing.T) { + msg := []byte("Test message") + for i := 0; i < 10; i++ { + go SaveMail(fmt.Sprintf("%s%d", testUID, i), msg, bs) + } +} diff --git a/internal/imap/imap.go b/internal/imap/imap.go new file mode 100644 index 00000000..b4736a7e --- /dev/null +++ b/internal/imap/imap.go @@ -0,0 +1,35 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package imap + +import "github.com/ProtonMail/proton-bridge/pkg/config" + +const ( + fetchMessagesWorkers = 5 // In how many workers to fetch message (group list on IMAP). + fetchAttachmentsWorkers = 5 // In how many workers to fetch attachments (for one message). + + clientAppleMail = "Mac OS X Mail" //nolint[deadcode] + clientThunderbird = "Thunderbird" //nolint[deadcode] + clientOutlookMac = "Microsoft Outlook for Mac" //nolint[deadcode] + clientOutlookWin = "Microsoft Outlook" //nolint[deadcode] + clientNone = "" +) + +var ( + log = config.GetLogEntry("imap") //nolint[gochecknoglobals] +) diff --git a/internal/imap/mailbox.go b/internal/imap/mailbox.go new file mode 100644 index 00000000..46841754 --- /dev/null +++ b/internal/imap/mailbox.go @@ -0,0 +1,187 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package imap + +import ( + "strings" + + "github.com/ProtonMail/proton-bridge/pkg/message" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/emersion/go-imap" + specialuse "github.com/emersion/go-imap-specialuse" + "github.com/sirupsen/logrus" +) + +type imapMailbox struct { + panicHandler panicHandler + user *imapUser + name string + + log *logrus.Entry + + storeUser storeUserProvider + storeAddress storeAddressProvider + storeMailbox storeMailboxProvider +} + +// newIMAPMailbox returns struct implementing go-imap/mailbox interface. +func newIMAPMailbox(panicHandler panicHandler, user *imapUser, storeMailbox storeMailboxProvider) *imapMailbox { + return &imapMailbox{ + panicHandler: panicHandler, + user: user, + name: storeMailbox.Name(), + + log: log. + WithField("addressID", user.storeAddress.AddressID()). + WithField("userID", user.storeUser.UserID()). + WithField("labelID", storeMailbox.LabelID()), + + storeUser: user.storeUser, + storeAddress: user.storeAddress, + storeMailbox: storeMailbox, + } +} + +// Name returns this mailbox name. +func (im *imapMailbox) Name() string { + // Called from go-imap in goroutines - we need to handle panics for each function. + defer im.panicHandler.HandlePanic() + + return im.name +} + +// Info returns this mailbox info. +func (im *imapMailbox) Info() (*imap.MailboxInfo, error) { + // Called from go-imap in goroutines - we need to handle panics for each function. + defer im.panicHandler.HandlePanic() + + info := &imap.MailboxInfo{ + Attributes: im.getFlags(), + Delimiter: im.storeMailbox.GetDelimiter(), + Name: im.name, + } + + return info, nil +} + +func (im *imapMailbox) getFlags() []string { + flags := []string{imap.NoInferiorsAttr} // Subfolders are not yet supported by API. + switch im.storeMailbox.LabelID() { + case pmapi.SentLabel: + flags = append(flags, specialuse.Sent) + case pmapi.TrashLabel: + flags = append(flags, specialuse.Trash) + case pmapi.SpamLabel: + flags = append(flags, specialuse.Junk) + case pmapi.ArchiveLabel: + flags = append(flags, specialuse.Archive) + case pmapi.AllMailLabel: + flags = append(flags, specialuse.All) + case pmapi.DraftLabel: + flags = append(flags, specialuse.Drafts) + } + + return flags +} + +// Status returns this mailbox status. The fields Name, Flags and +// PermanentFlags in the returned MailboxStatus must be always populated. This +// function does not affect the state of any messages in the mailbox. See RFC +// 3501 section 6.3.10 for a list of items that can be requested. +// +// It always returns the state of DB (which could be different to server status). +// Additionally it checks that all stored numbers are same as in DB and polls events if needed. +func (im *imapMailbox) Status(items []string) (*imap.MailboxStatus, error) { + // Called from go-imap in goroutines - we need to handle panics for each function. + defer im.panicHandler.HandlePanic() + + l := log.WithField("status-label", im.storeMailbox.LabelID()) + l.Data["user"] = im.storeUser.UserID() + l.Data["address"] = im.storeAddress.AddressID() + status := imap.NewMailboxStatus(im.name, items) + status.UidValidity = im.storeMailbox.UIDValidity() + status.PermanentFlags = []string{ + imap.SeenFlag, strings.ToUpper(imap.SeenFlag), + imap.FlaggedFlag, strings.ToUpper(imap.FlaggedFlag), + imap.DeletedFlag, strings.ToUpper(imap.DeletedFlag), + imap.DraftFlag, strings.ToUpper(imap.DraftFlag), + message.AppleMailJunkFlag, + message.ThunderbirdJunkFlag, + message.ThunderbirdNonJunkFlag, + } + + dbTotal, dbUnread, err := im.storeMailbox.GetCounts() + l.Debugln("DB: total", dbTotal, "unread", dbUnread, "err", err) + if err == nil { + status.Messages = uint32(dbTotal) + status.Unseen = uint32(dbUnread) + } + + if status.UidNext, err = im.storeMailbox.GetNextUID(); err != nil { + return nil, err + } + + return status, nil +} + +// Subscribe adds the mailbox to the server's set of "active" or "subscribed" mailboxes. +func (im *imapMailbox) Subscribe() error { + // Called from go-imap in goroutines - we need to handle panics for each function. + defer im.panicHandler.HandlePanic() + + label := im.storeMailbox.LabelID() + if !im.user.isSubscribed(label) { + im.user.removeFromCache(SubscriptionException, label) + } + + return nil +} + +// Unsubscribe removes the mailbox to the server's set of "active" or "subscribed" mailboxes. +func (im *imapMailbox) Unsubscribe() error { + // Called from go-imap in goroutines - we need to handle panics for each function. + defer im.panicHandler.HandlePanic() + + label := im.storeMailbox.LabelID() + if im.user.isSubscribed(label) { + im.user.addToCache(SubscriptionException, label) + } + + return nil +} + +// Check requests a checkpoint of the currently selected mailbox. A checkpoint +// refers to any implementation-dependent housekeeping associated with the +// mailbox (e.g., resolving the server's in-memory state of the mailbox with +// the state on its disk). A checkpoint MAY take a non-instantaneous amount of +// real time to complete. If a server implementation has no such housekeeping +// considerations, CHECK is equivalent to NOOP. +func (im *imapMailbox) Check() error { + return nil +} + +// Expunge permanently removes all messages that have the \Deleted flag set +// from the currently selected mailbox. +// Our messages do not have \Deleted flag, nothing to do here. +func (im *imapMailbox) Expunge() error { + return nil +} + +func (im *imapMailbox) ListQuotas() ([]string, error) { + return []string{""}, nil +} diff --git a/internal/imap/mailbox_message.go b/internal/imap/mailbox_message.go new file mode 100644 index 00000000..84daafa9 --- /dev/null +++ b/internal/imap/mailbox_message.go @@ -0,0 +1,784 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package imap + +import ( + "bytes" + "encoding/base64" + "fmt" + "io" + "io/ioutil" + "mime/multipart" + "net/mail" + "net/textproto" + "regexp" + "sort" + "strings" + "time" + + pmcrypto "github.com/ProtonMail/gopenpgp/crypto" + "github.com/ProtonMail/proton-bridge/internal/imap/cache" + "github.com/ProtonMail/proton-bridge/internal/imap/uidplus" + "github.com/ProtonMail/proton-bridge/pkg/message" + "github.com/ProtonMail/proton-bridge/pkg/parallel" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/emersion/go-imap" + "github.com/emersion/go-textwrapper" + "github.com/hashicorp/go-multierror" + enmime "github.com/jhillyerd/enmime" + "github.com/pkg/errors" + openpgperrors "golang.org/x/crypto/openpgp/errors" +) + +type doNotCacheError struct{ e error } + +func (dnc *doNotCacheError) Error() string { return dnc.e.Error() } +func (dnc *doNotCacheError) add(err error) { dnc.e = multierror.Append(dnc.e, err) } +func (dnc *doNotCacheError) errorOrNil() error { + if dnc == nil { + return nil + } + + if dnc.e != nil { + return dnc + } + + return nil +} + +// CreateMessage appends a new message to this mailbox. The \Recent flag will +// be added regardless of whether flags is empty or not. If date is nil, the +// current time will be used. +// +// If the Backend implements Updater, it must notify the client immediately +// via a mailbox update. +func (im *imapMailbox) CreateMessage(flags []string, date time.Time, body imap.Literal) error { // nolint[funlen] + // Called from go-imap in goroutines - we need to handle panics for each function. + defer im.panicHandler.HandlePanic() + + m, _, _, readers, err := message.Parse(body, "", "") + if err != nil { + return err + } + + addr := im.storeAddress.APIAddress() + if addr == nil { + return errors.New("no available address for encryption") + } + m.AddressID = addr.ID + kr := addr.KeyRing() + + // Handle imported messages which have no "Sender" address. + // This sometimes occurs with outlook which reports errors as imported emails or for drafts. + if m.Sender == nil { + im.log.Warning("Append: Missing email sender. Will use main address") + m.Sender = &mail.Address{ + Name: "", + Address: addr.Email, + } + } + + // "Drafts" needs to call special API routes. + // Clients always append the whole message again and remove the old one. + if im.storeMailbox.LabelID() == pmapi.DraftLabel { + // Sender address needs to be sanitised (drafts need to match cases exactly). + m.Sender.Address = pmapi.ConstructAddress(m.Sender.Address, addr.Email) + + draft, _, err := im.user.storeUser.CreateDraft(kr, m, readers, "", "", "") + if err != nil { + return errors.Wrap(err, "failed to create draft") + } + + targetSeq := im.storeMailbox.GetUIDList([]string{draft.ID}) + return uidplus.AppendResponse(im.storeMailbox.UIDValidity(), targetSeq) + } + + // We need to make sure this is an import, and not a sent message from this account + // (sent messages from the account will be added by the event loop). + if im.storeMailbox.LabelID() == pmapi.SentLabel { + sanitizedSender := pmapi.SanitizeEmail(m.Sender.Address) + + // Check whether this message was sent by a bridge user. + user, err := im.user.backend.bridge.GetUser(sanitizedSender) + if err == nil && user.ID() == im.storeUser.UserID() { + logEntry := im.log.WithField("addr", sanitizedSender).WithField("extID", m.Header.Get("Message-Id")) + + // If we find the message in the store already, we can skip importing it. + if foundUID := im.storeMailbox.GetUIDByHeader(&m.Header); foundUID != uint32(0) { + logEntry.Info("Ignoring APPEND of duplicate to Sent folder") + return uidplus.AppendResponse(im.storeMailbox.UIDValidity(), &uidplus.OrderedSeq{foundUID}) + } + + // We didn't find the message in the store, so we are currently sending it. + logEntry.WithField("time", date).Info("No matching UID, continuing APPEND to Sent") + + // For now we don't import user's own messages to Sent because GetUIDByHeader is not smart enough. + // This will be fixed in GODT-143. + return nil + } + + // This is an APPEND to the Sent folder, so we will set the sent flag + m.Flags |= pmapi.FlagSent + } + + message.ParseFlags(m, flags) + if !date.IsZero() { + m.Time = date.Unix() + } + + internalID := m.Header.Get("X-Pm-Internal-Id") + references := m.Header.Get("References") + referenceList := strings.Fields(references) + + if len(referenceList) > 0 { + lastReference := referenceList[len(referenceList)-1] + // In case we are using a mail client which corrupts headers, try "References" too. + re := regexp.MustCompile("<[a-zA-Z0-9-_=]*@protonmail.internalid>") + match := re.FindString(lastReference) + if match != "" { + internalID = match[1 : len(match)-len("@protonmail.internalid>")] + } + } + + // Avoid appending a message which is already on the server. Apply the new + // label instead. This sometimes happens which Outlook (it uses APPEND instead of COPY). + if internalID != "" { + // Check to see if this belongs to a different address in split mode or another ProtonMail account. + msg, err := im.storeMailbox.GetMessage(internalID) + if err == nil && (im.user.user.IsCombinedAddressMode() || (im.storeAddress.AddressID() == msg.Message().AddressID)) { + IDs := []string{internalID} + + err = im.storeMailbox.LabelMessages(IDs) + if err != nil { + return err + } + + targetSeq := im.storeMailbox.GetUIDList([]string{m.ID}) + return uidplus.AppendResponse(im.storeMailbox.UIDValidity(), targetSeq) + } + } + + im.log.Info("Importing external message") + if err := im.importMessage(m, readers, kr); err != nil { + im.log.Error("Import failed: ", err) + return err + } + + targetSeq := im.storeMailbox.GetUIDList([]string{m.ID}) + return uidplus.AppendResponse(im.storeMailbox.UIDValidity(), targetSeq) +} + +func (im *imapMailbox) importMessage(m *pmapi.Message, readers []io.Reader, kr *pmcrypto.KeyRing) (err error) { // nolint[funlen] + b := &bytes.Buffer{} + + // Overwrite content for main header for import. + // Even if message has just simple body we should upload as multipart/mixed. + // Each part has encrypted body and header reflects the original header. + mainHeader := message.GetHeader(m) + mainHeader.Set("Content-Type", "multipart/mixed; boundary="+message.GetBoundary(m)) + mainHeader.Del("Content-Disposition") + mainHeader.Del("Content-Transfer-Encoding") + if err = writeHeader(b, mainHeader); err != nil { + return + } + mw := multipart.NewWriter(b) + if err = mw.SetBoundary(message.GetBoundary(m)); err != nil { + return + } + + // Write the body part. + bodyHeader := make(textproto.MIMEHeader) + bodyHeader.Set("Content-Type", m.MIMEType+"; charset=utf-8") + bodyHeader.Set("Content-Disposition", "inline") + bodyHeader.Set("Content-Transfer-Encoding", "7bit") + + var p io.Writer + if p, err = mw.CreatePart(bodyHeader); err != nil { + return + } + // First, encrypt the message body. + if err = m.Encrypt(kr, kr); err != nil { + return err + } + if _, err := io.WriteString(p, m.Body); err != nil { + return err + } + + // Write the attachments parts. + for i := 0; i < len(m.Attachments); i++ { + att := m.Attachments[i] + r := readers[i] + h := message.GetAttachmentHeader(att) + if p, err = mw.CreatePart(h); err != nil { + return + } + // Create line wrapper writer. + ww := textwrapper.NewRFC822(p) + + // Create base64 writer. + bw := base64.NewEncoder(base64.StdEncoding, ww) + + data, err := ioutil.ReadAll(r) + if err != nil { + return err + } + + // Create encrypted writer. + pgpMessage, err := kr.Encrypt(pmcrypto.NewPlainMessage(data), nil) + if err != nil { + return err + } + if _, err := bw.Write(pgpMessage.GetBinary()); err != nil { + return err + } + if err := bw.Close(); err != nil { + return err + } + } + + if err := mw.Close(); err != nil { + return err + } + + labels := []string{} + for _, l := range m.LabelIDs { + if l == pmapi.StarredLabel { + labels = append(labels, pmapi.StarredLabel) + } + } + + return im.storeMailbox.ImportMessage(m, b.Bytes(), labels) +} + +func (im *imapMailbox) getMessage(storeMessage storeMessageProvider, items []string) (msg *imap.Message, err error) { + im.log.WithField("msgID", storeMessage.ID()).Trace("Getting message") + + seqNum, err := storeMessage.SequenceNumber() + if err != nil { + return + } + + m := storeMessage.Message() + + msg = imap.NewMessage(seqNum, items) + for _, item := range items { + switch item { + case imap.EnvelopeMsgAttr: + msg.Envelope = message.GetEnvelope(m) + case imap.BodyMsgAttr, imap.BodyStructureMsgAttr: + var structure *message.BodyStructure + if structure, _, err = im.getBodyStructure(storeMessage); err != nil { + return + } + if msg.BodyStructure, err = structure.IMAPBodyStructure([]int{}); err != nil { + return + } + case imap.FlagsMsgAttr: + msg.Flags = message.GetFlags(m) + case imap.InternalDateMsgAttr: + msg.InternalDate = time.Unix(m.Time, 0) + case imap.SizeMsgAttr: + // Size attribute on the server counts encrypted data. The value is cleared + // on our part and we need to compute "real" size of decrypted data. + if m.Size <= 0 { + if _, _, err = im.getBodyStructure(storeMessage); err != nil { + return + } + } + msg.Size = uint32(m.Size) + case imap.UidMsgAttr: + msg.Uid, err = storeMessage.UID() + if err != nil { + return nil, err + } + default: + s := item + + var section *imap.BodySectionName + if section, err = imap.NewBodySectionName(s); err != nil { + err = nil // Ignore error + break + } + + var literal imap.Literal + if literal, err = im.getMessageBodySection(storeMessage, section); err != nil { + return + } + + msg.Body[section] = literal + } + } + + return msg, err +} + +func (im *imapMailbox) getBodyStructure(storeMessage storeMessageProvider) ( + structure *message.BodyStructure, + bodyReader *bytes.Reader, err error, +) { + m := storeMessage.Message() + id := im.storeUser.UserID() + m.ID + cache.BuildLock(id) + if bodyReader, structure = cache.LoadMail(id); bodyReader.Len() == 0 || structure == nil { + var body []byte + structure, body, err = im.buildMessage(m) + if err == nil && structure != nil && len(body) > 0 { + m.Size = int64(len(body)) + if err := storeMessage.SetSize(m.Size); err != nil { + im.log.WithError(err). + WithField("newSize", m.Size). + WithField("msgID", m.ID). + Warn("Cannot update size while building") + } + if err := storeMessage.SetContentTypeAndHeader(m.MIMEType, m.Header); err != nil { + im.log.WithError(err). + WithField("msgID", m.ID). + Warn("Cannot update header while building") + } + // Drafts can change and we don't want to cache them. + if !isMessageInDraftFolder(m) { + cache.SaveMail(id, body, structure) + } + bodyReader = bytes.NewReader(body) + } + if _, ok := err.(*doNotCacheError); ok { + im.log.WithField("msgID", m.ID).Errorf("do not cache message: %v", err) + err = nil + bodyReader = bytes.NewReader(body) + } + } + cache.BuildUnlock(id) + return structure, bodyReader, err +} + +func isMessageInDraftFolder(m *pmapi.Message) bool { + for _, labelID := range m.LabelIDs { + if labelID == pmapi.DraftLabel { + return true + } + } + return false +} + +// This will download message (or read from cache) and pick up the section, +// extract data (header,body, both) and trim the output if needed. +func (im *imapMailbox) getMessageBodySection(storeMessage storeMessageProvider, section *imap.BodySectionName) (literal imap.Literal, err error) { // nolint[funlen] + var ( + structure *message.BodyStructure + bodyReader *bytes.Reader + header textproto.MIMEHeader + response []byte + ) + + im.log.WithField("msgID", storeMessage.ID()).Trace("Getting message body") + + m := storeMessage.Message() + + if len(section.Path) == 0 && section.Specifier == imap.HeaderSpecifier { + // We can extract message header without decrypting. + header = message.GetHeader(m) + // We need to ensure we use the correct content-type, + // otherwise AppleMail expects `text/plain` in HTML mails. + if header.Get("Content-Type") == "" { + if err = im.fetchMessage(m); err != nil { + return + } + if _, err = im.setMessageContentType(m); err != nil { + return + } + if err = storeMessage.SetContentTypeAndHeader(m.MIMEType, m.Header); err != nil { + return + } + header = message.GetHeader(m) + } + } else { + // The rest of cases need download and decrypt. + structure, bodyReader, err = im.getBodyStructure(storeMessage) + if err != nil { + return + } + + switch { + case section.Specifier == imap.EntireSpecifier && len(section.Path) == 0: + // An empty section specification refers to the entire message, including the header. + response, err = structure.GetSection(bodyReader, section.Path) + case section.Specifier == imap.TextSpecifier || (section.Specifier == imap.EntireSpecifier && len(section.Path) != 0): + // The TEXT specifier refers to the content of the message (or section), omitting the [RFC-2822] header. + // Non-empty section with no specifier (imap.EntireSpecifier) refers to section content without header. + response, err = structure.GetSectionContent(bodyReader, section.Path) + case section.Specifier == imap.MimeSpecifier: + // The MIME part specifier refers to the [MIME-IMB] header for this part. + fallthrough + case section.Specifier == imap.HeaderSpecifier: + header, err = structure.GetSectionHeader(section.Path) + default: + err = errors.New("Unknown specifier " + section.Specifier) + } + } + + if err != nil { + return + } + + // Filter header. Options are: all fields, only selected fields, all fields except selected. + if header != nil { + // remove fields + if len(section.Fields) != 0 && section.NotFields { + for _, field := range section.Fields { + header.Del(field) + } + } + + fields := make([]string, 0, len(header)) + if len(section.Fields) == 0 || section.NotFields { // add all and sort + for f := range header { + fields = append(fields, f) + } + sort.Strings(fields) + } else { // add only requested (in requested order) + for _, f := range section.Fields { + fields = append(fields, textproto.CanonicalMIMEHeaderKey(f)) + } + } + + headerBuf := &bytes.Buffer{} + for _, canonical := range fields { + if values, ok := header[canonical]; !ok { + continue + } else { + for _, val := range values { + fmt.Fprintf(headerBuf, "%s: %s\r\n", canonical, val) + } + } + } + response = headerBuf.Bytes() + } + + // Trim any output if requested. + literal = bytes.NewBuffer(section.ExtractPartial(response)) + return literal, nil +} + +func (im *imapMailbox) fetchMessage(m *pmapi.Message) (err error) { + im.log.Trace("Fetching message") + + complete, err := im.storeMailbox.FetchMessage(m.ID) + if err != nil { + im.log.WithError(err).Error("Could not get message from store") + return + } + + *m = *complete.Message() + + return +} + +func (im *imapMailbox) customMessage(m *pmapi.Message, err error, attachBody bool) { + // Assuming quoted-printable. + origBody := strings.Replace(m.Body, "=", "=3D", -1) + m.Body = "Content-Type: text/html\r\n" + m.Body = "\n\n" + m.Body += "

    \n" + + if attachBody { + m.Body += "
    \n"
    +		m.Body += origBody
    +		m.Body += "\n
    \n" + } + + m.Body += "" + m.MIMEType = "text/html" + + // NOTE: we need to set header in custom message header, so we check that is non-nil. + if m.Header == nil { + m.Header = make(mail.Header) + } +} + +func (im *imapMailbox) writeMessageBody(w io.Writer, m *pmapi.Message) (err error) { + im.log.Trace("Writing message body") + + if m.Body == "" { + im.log.Trace("While writing message body, noticed message body is null, need to fetch") + if err = im.fetchMessage(m); err != nil { + return + } + } + + kr := im.user.client.KeyRingForAddressID(m.AddressID) + err = message.WriteBody(w, kr, m) + if err != nil { + im.customMessage(m, err, true) + _, _ = io.WriteString(w, m.Body) + err = nil + } + + return +} + +func (im *imapMailbox) writeAndParseMIMEBody(m *pmapi.Message) (mime *enmime.Envelope, err error) { //nolint[unused] + b := &bytes.Buffer{} + if err = im.writeMessageBody(b, m); err != nil { + return + } + + mime, err = enmime.ReadEnvelope(b) + + return +} + +func (im *imapMailbox) writeAttachmentBody(w io.Writer, m *pmapi.Message, att *pmapi.Attachment) (err error) { + // Retrieve encrypted attachment. + r, err := im.user.client.GetAttachment(att.ID) + if err != nil { + return + } + defer r.Close() //nolint[errcheck] + + kr := im.user.client.KeyRingForAddressID(m.AddressID) + if err = message.WriteAttachmentBody(w, kr, m, att, r); err != nil { + // Returning an error here makes certain mail clients behave badly, + // trying to retrieve the message again and again. + im.log.Warn("Cannot write attachment body: ", err) + err = nil + } + return +} + +func (im *imapMailbox) writeRelatedPart(p io.Writer, m *pmapi.Message, inlines []*pmapi.Attachment) (err error) { + related := multipart.NewWriter(p) + + _ = related.SetBoundary(message.GetRelatedBoundary(m)) + + buf := &bytes.Buffer{} + if err = im.writeMessageBody(buf, m); err != nil { + return + } + + // Write the body part. + h := message.GetBodyHeader(m) + + if p, err = related.CreatePart(h); err != nil { + return + } + + _, _ = buf.WriteTo(p) + + for _, inline := range inlines { + buf = &bytes.Buffer{} + if err = im.writeAttachmentBody(buf, m, inline); err != nil { + return + } + + h := message.GetAttachmentHeader(inline) + if p, err = related.CreatePart(h); err != nil { + return + } + _, _ = buf.WriteTo(p) + } + + _ = related.Close() + return nil +} + +const ( + noMultipart = iota // only body + simpleMultipart // body + attachment or inline + complexMultipart // mixed, rfc822, alternatives, ... +) + +func (im *imapMailbox) setMessageContentType(m *pmapi.Message) (multipartType int, err error) { + if m.MIMEType == "" { + err = fmt.Errorf("trying to set Content-Type without MIME TYPE") + return + } + // message.MIMEType can have just three values from our server: + // * `text/html` (refers to body type, but might contain attachments and inlines) + // * `text/plain` (refers to body type, but might contain attachments and inlines) + // * `multipart/mixed` (refers to external message with multipart structure) + // The proper header content fields must be set and saved to DB based MIMEType and content. + multipartType = noMultipart + if m.MIMEType == pmapi.ContentTypeMultipartMixed { + multipartType = complexMultipart + } else if m.NumAttachments != 0 { + multipartType = simpleMultipart + } + + h := textproto.MIMEHeader(m.Header) + if multipartType == noMultipart { + message.SetBodyContentFields(&h, m) + } else { + h.Set("Content-Type", + fmt.Sprintf("%s; boundary=%s", "multipart/mixed", message.GetBoundary(m)), + ) + } + m.Header = mail.Header(h) + + return +} + +// buildMessage from PM to IMAP. +func (im *imapMailbox) buildMessage(m *pmapi.Message) (structure *message.BodyStructure, msgBody []byte, err error) { + im.log.Trace("Building message") + + var errNoCache doNotCacheError + + // If fetch or decryption fails we need to change the MIMEType (in customMessage). + err = im.fetchMessage(m) + if err != nil { + return + } + + kr := im.user.client.KeyRingForAddressID(m.AddressID) + errDecrypt := m.Decrypt(kr) + + if errDecrypt != nil && errDecrypt != openpgperrors.ErrSignatureExpired { + errNoCache.add(errDecrypt) + im.customMessage(m, errDecrypt, true) + } + + // Inner function can fail even when message is decrypted. + // #1048 For example we have problem with double-encrypted messages + // which seems as still encrypted and we try them to decrypt again + // and that fails. For any building error is better to return custom + // message than error because it will not be fixed and users would + // get error message all the time and could not see some messages. + structure, msgBody, err = im.buildMessageInner(m, kr) + if err == pmapi.ErrAPINotReachable || err == pmapi.ErrInvalidToken || err == pmapi.ErrUpgradeApplication { + return nil, nil, err + } else if err != nil { + errNoCache.add(err) + im.customMessage(m, err, true) + structure, msgBody, err = im.buildMessageInner(m, kr) + if err != nil { + return nil, nil, err + } + } + + err = errNoCache.errorOrNil() + + return structure, msgBody, err +} + +func (im *imapMailbox) buildMessageInner(m *pmapi.Message, kr *pmcrypto.KeyRing) (structure *message.BodyStructure, msgBody []byte, err error) { // nolint[funlen] + multipartType, err := im.setMessageContentType(m) + if err != nil { + return + } + + tmpBuf := &bytes.Buffer{} + mainHeader := message.GetHeader(m) + if err = writeHeader(tmpBuf, mainHeader); err != nil { + return + } + _, _ = io.WriteString(tmpBuf, "\r\n") + + switch multipartType { + case noMultipart: + err = message.WriteBody(tmpBuf, kr, m) + if err != nil { + return + } + case complexMultipart: + _, _ = io.WriteString(tmpBuf, "\r\n--"+message.GetBoundary(m)+"\r\n") + err = message.WriteBody(tmpBuf, kr, m) + if err != nil { + return + } + _, _ = io.WriteString(tmpBuf, "\r\n--"+message.GetBoundary(m)+"--\r\n") + case simpleMultipart: + atts, inlines := message.SeparateInlineAttachments(m) + mw := multipart.NewWriter(tmpBuf) + _ = mw.SetBoundary(message.GetBoundary(m)) + + var partWriter io.Writer + + if len(inlines) > 0 { + relatedHeader := message.GetRelatedHeader(m) + if partWriter, err = mw.CreatePart(relatedHeader); err != nil { + return + } + _ = im.writeRelatedPart(partWriter, m, inlines) + } else { + buf := &bytes.Buffer{} + if err = im.writeMessageBody(buf, m); err != nil { + return + } + + // Write the body part. + bodyHeader := message.GetBodyHeader(m) + if partWriter, err = mw.CreatePart(bodyHeader); err != nil { + return + } + + _, _ = buf.WriteTo(partWriter) + } + + // Write the attachments parts. + input := make([]interface{}, len(atts)) + for i, att := range atts { + input[i] = att + } + + processCallback := func(value interface{}) (interface{}, error) { + att := value.(*pmapi.Attachment) + + buf := &bytes.Buffer{} + if err = im.writeAttachmentBody(buf, m, att); err != nil { + return nil, err + } + return buf, nil + } + + collectCallback := func(idx int, value interface{}) error { + buf := value.(*bytes.Buffer) + defer buf.Reset() + att := atts[idx] + + attachmentHeader := message.GetAttachmentHeader(att) + if partWriter, err = mw.CreatePart(attachmentHeader); err != nil { + return err + } + + _, _ = buf.WriteTo(partWriter) + return nil + } + + err = parallel.RunParallel(fetchAttachmentsWorkers, input, processCallback, collectCallback) + if err != nil { + return + } + + _ = mw.Close() + default: + fmt.Fprintf(tmpBuf, "\r\n\r\nUknown multipart type: %d\r\n\r\n", multipartType) + } + + // We need to copy buffer before building body structure. + msgBody = tmpBuf.Bytes() + structure, err = message.NewBodyStructure(tmpBuf) + if err != nil { + // NOTE: We need to set structure if it fails and is empty. + if structure == nil { + structure = &message.BodyStructure{} + } + } + return structure, msgBody, err +} diff --git a/internal/imap/mailbox_message_test.go b/internal/imap/mailbox_message_test.go new file mode 100644 index 00000000..faaeb457 --- /dev/null +++ b/internal/imap/mailbox_message_test.go @@ -0,0 +1,41 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package imap + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDoNotCache(t *testing.T) { + var dnc doNotCacheError + require.NoError(t, dnc.errorOrNil()) + _, ok := dnc.errorOrNil().(*doNotCacheError) + require.True(t, !ok, "should not be type doNotCacheError") + + dnc.add(errors.New("first")) + require.True(t, dnc.errorOrNil() != nil, "should be error") + _, ok = dnc.errorOrNil().(*doNotCacheError) + require.True(t, ok, "should be type doNotCacheError") + + dnc.add(errors.New("second")) + dnc.add(errors.New("third")) + t.Log(dnc.errorOrNil()) +} diff --git a/internal/imap/mailbox_messages.go b/internal/imap/mailbox_messages.go new file mode 100644 index 00000000..70294756 --- /dev/null +++ b/internal/imap/mailbox_messages.go @@ -0,0 +1,490 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package imap + +import ( + "errors" + "fmt" + "net/mail" + "strings" + "sync" + "time" + + "github.com/ProtonMail/proton-bridge/internal/imap/uidplus" + "github.com/ProtonMail/proton-bridge/pkg/message" + "github.com/ProtonMail/proton-bridge/pkg/parallel" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/emersion/go-imap" + "github.com/sirupsen/logrus" +) + +// UpdateMessagesFlags alters flags for the specified message(s). +// +// If the Backend implements Updater, it must notify the client immediately +// via a message update. +func (im *imapMailbox) UpdateMessagesFlags(uid bool, seqSet *imap.SeqSet, operation imap.FlagsOp, flags []string) error { + log.WithFields(logrus.Fields{ + "flags": flags, + "operation": operation, + }).Debug("Updating message flags") + + // Called from go-imap in goroutines - we need to handle panics for each function. + defer im.panicHandler.HandlePanic() + + messageIDs, err := im.apiIDsFromSeqSet(uid, seqSet) + if err != nil || len(messageIDs) == 0 { + return err + } + + for _, f := range flags { + switch f { + case imap.SeenFlag: + switch operation { + case imap.SetFlags, imap.AddFlags: + _ = im.storeMailbox.MarkMessagesRead(messageIDs) + case imap.RemoveFlags: + _ = im.storeMailbox.MarkMessagesUnread(messageIDs) + } + case imap.FlaggedFlag: + switch operation { + case imap.SetFlags, imap.AddFlags: + _ = im.storeMailbox.MarkMessagesStarred(messageIDs) + case imap.RemoveFlags: + _ = im.storeMailbox.MarkMessagesUnstarred(messageIDs) + } + case imap.DeletedFlag: + if operation == imap.RemoveFlags { + break // Nothing to do, no message has the \Deleted flag. + } + _ = im.storeMailbox.DeleteMessages(messageIDs) + case imap.AnsweredFlag, imap.DraftFlag, imap.RecentFlag: + // Not supported. + case message.AppleMailJunkFlag, message.ThunderbirdJunkFlag: + storeMailbox, err := im.storeAddress.GetMailbox(pmapi.SpamLabel) + if err != nil { + return err + } + + // Handle custom junk flags for Apple Mail and Thunderbird. + switch operation { + // No label removal is necessary because Spam and Inbox are both exclusive labels so the backend + // will automatically take care of label removal. + case imap.SetFlags, imap.AddFlags: + _ = storeMailbox.LabelMessages(messageIDs) + case imap.RemoveFlags: + _ = storeMailbox.UnlabelMessages(messageIDs) + } + } + } + + return nil +} + +// CopyMessages copies the specified message(s) to the end of the specified +// destination mailbox. The flags and internal date of the message(s) SHOULD +// be preserved, and the Recent flag SHOULD be set, in the copy. +func (im *imapMailbox) CopyMessages(uid bool, seqSet *imap.SeqSet, targetLabel string) error { + // Called from go-imap in goroutines - we need to handle panics for each function. + defer im.panicHandler.HandlePanic() + + messageIDs, err := im.apiIDsFromSeqSet(uid, seqSet) + if err != nil || len(messageIDs) == 0 { + return err + } + + // It is needed to get UID list before LabelingMessages because + // messages can be removed from source during labeling (e.g. folder1 -> folder2). + sourceSeqSet := im.storeMailbox.GetUIDList(messageIDs) + + targetStoreMBX, err := im.storeAddress.GetMailbox(targetLabel) + if err != nil { + return err + } + if err = targetStoreMBX.LabelMessages(messageIDs); err != nil { + return err + } + + targetSeqSet := targetStoreMBX.GetUIDList(messageIDs) + return uidplus.CopyResponse(im.storeMailbox.UIDValidity(), sourceSeqSet, targetSeqSet) +} + +// MoveMessages adds dest's label and removes this mailbox' label from each message. +// +// This should not be used until MOVE extension has option to send UIDPLUS +// responses. +func (im *imapMailbox) MoveMessages(uid bool, seqSet *imap.SeqSet, newLabel string) error { + // Called from go-imap in goroutines - we need to handle panics for each function. + defer im.panicHandler.HandlePanic() + + messageIDs, err := im.apiIDsFromSeqSet(uid, seqSet) + if err != nil || len(messageIDs) == 0 { + return err + } + storeMailbox, err := im.storeAddress.GetMailbox(newLabel) + if err != nil { + return err + } + // Label messages first to not loss them. If message is only in trash and we unlabel + // it, it will be removed completely and we cannot label it back. + if err := storeMailbox.LabelMessages(messageIDs); err != nil { + return err + } + return im.storeMailbox.UnlabelMessages(messageIDs) +} + +// SearchMessages searches messages. The returned list must contain UIDs if +// uid is set to true, or sequence numbers otherwise. +func (im *imapMailbox) SearchMessages(isUID bool, criteria *imap.SearchCriteria) (ids []uint32, err error) { //nolint[gocyclo] + // Called from go-imap in goroutines - we need to handle panics for each function. + defer im.panicHandler.HandlePanic() + + if criteria.Not != nil || criteria.Or[0] != nil { + return nil, errors.New("unsupported search query") + } + + if criteria.Body != "" || criteria.Text != "" { + log.Warn("Body and Text criteria not applied.") + } + + var apiIDs []string + if criteria.SeqSet != nil { + apiIDs, err = im.apiIDsFromSeqSet(false, criteria.SeqSet) + } else { + apiIDs, err = im.storeMailbox.GetAPIIDsFromSequenceRange(1, 0) + } + if err != nil { + return nil, err + } + + var apiIDsFromUID []string + if criteria.Uid != nil { + if apiIDs, err := im.apiIDsFromSeqSet(true, criteria.Uid); err == nil { + apiIDsFromUID = append(apiIDsFromUID, apiIDs...) + } + } + + // Apply filters. + for _, apiID := range apiIDs { + // Filter on UIDs. + if len(apiIDsFromUID) > 0 && !isStringInList(apiIDsFromUID, apiID) { + continue + } + + // Get message. + storeMessage, err := im.storeMailbox.GetMessage(apiID) + if err != nil { + log.Warnf("search messages: cannot get message %q from db: %v", apiID, err) + continue + } + m := storeMessage.Message() + + // Filter addresses. + if criteria.From != "" && !addressMatch([]*mail.Address{m.Sender}, criteria.From) { + continue + } + if criteria.To != "" && !addressMatch(m.ToList, criteria.To) { + continue + } + if criteria.Cc != "" && !addressMatch(m.CCList, criteria.Cc) { + continue + } + if criteria.Bcc != "" && !addressMatch(m.BCCList, criteria.Bcc) { + continue + } + + // Filter strings. + if criteria.Subject != "" && !strings.Contains(strings.ToLower(m.Subject), strings.ToLower(criteria.Subject)) { + continue + } + if criteria.Keyword != "" && !hasKeyword(m, criteria.Keyword) { + continue + } + if criteria.Unkeyword != "" && hasKeyword(m, criteria.Unkeyword) { + continue + } + if criteria.Header[0] != "" { + h := message.GetHeader(m) + if val := h.Get(criteria.Header[0]); val == "" { + continue // Field is not in header. + } else if criteria.Header[1] != "" && !strings.Contains(strings.ToLower(val), strings.ToLower(criteria.Header[1])) { + continue // Field is in header, second criteria is non-zero and field value not matched (case insensitive). + } + } + + // Filter flags. + if criteria.Flagged && !isStringInList(m.LabelIDs, pmapi.StarredLabel) { + continue + } + if criteria.Unflagged && isStringInList(m.LabelIDs, pmapi.StarredLabel) { + continue + } + if criteria.Seen && m.Unread == 1 { + continue + } + if criteria.Unseen && m.Unread == 0 { + continue + } + if criteria.Deleted { + continue + } + // if criteria.Undeleted { // All messages matches this criteria } + if criteria.Draft && (m.Has(pmapi.FlagSent) || m.Has(pmapi.FlagReceived)) { + continue + } + if criteria.Undraft && !(m.Has(pmapi.FlagSent) || m.Has(pmapi.FlagReceived)) { + continue + } + if criteria.Answered && !(m.Has(pmapi.FlagReplied) || m.Has(pmapi.FlagRepliedAll)) { + continue + } + if criteria.Unanswered && (m.Has(pmapi.FlagReplied) || m.Has(pmapi.FlagRepliedAll)) { + continue + } + if criteria.Recent && m.Has(pmapi.FlagOpened) { // opened means not recent + continue + } + if criteria.Old && !m.Has(pmapi.FlagOpened) { + continue + } + if criteria.New && !(!m.Has(pmapi.FlagOpened) && m.Unread == 1) { + continue + } + + // Filter internal date. + if !criteria.Before.IsZero() { + if truncated := criteria.Before.Truncate(24 * time.Hour); m.Time > truncated.Unix() { + continue + } + } + if !criteria.Since.IsZero() { + if truncated := criteria.Since.Truncate(24 * time.Hour); m.Time < truncated.Unix() { + continue + } + } + if !criteria.On.IsZero() { + truncated := criteria.On.Truncate(24 * time.Hour) + if m.Time < truncated.Unix() || m.Time > truncated.Add(24*time.Hour).Unix() { + continue + } + } + if !(criteria.SentBefore.IsZero() && criteria.SentSince.IsZero() && criteria.SentOn.IsZero()) { + if t, err := m.Header.Date(); err == nil && !t.IsZero() { + // Filter header date. + if !criteria.SentBefore.IsZero() { + if truncated := criteria.SentBefore.Truncate(24 * time.Hour); t.Unix() > truncated.Unix() { + continue + } + } + if !criteria.SentSince.IsZero() { + if truncated := criteria.SentSince.Truncate(24 * time.Hour); t.Unix() < truncated.Unix() { + continue + } + } + if !criteria.SentOn.IsZero() { + truncated := criteria.SentOn.Truncate(24 * time.Hour) + if t.Unix() < truncated.Unix() || t.Unix() > truncated.Add(24*time.Hour).Unix() { + continue + } + } + } + } + + // Filter size (only if size was already calculated). + if m.Size > 0 { + if criteria.Larger != 0 && m.Size <= int64(criteria.Larger) { + continue + } + if criteria.Smaller != 0 && m.Size >= int64(criteria.Smaller) { + continue + } + } + + // Add the ID to response. + var id uint32 + if isUID { + id, err = storeMessage.UID() + if err != nil { + return nil, err + } + } else { + id, err = storeMessage.SequenceNumber() + if err != nil { + return nil, err + } + } + ids = append(ids, id) + } + + return ids, nil +} + +// ListMessages returns a list of messages. seqset must be interpreted as UIDs +// if uid is set to true and as message sequence numbers otherwise. See RFC +// 3501 section 6.4.5 for a list of items that can be requested. +// +// Messages must be sent to msgResponse. When the function returns, msgResponse must be closed. +func (im *imapMailbox) ListMessages(isUID bool, seqSet *imap.SeqSet, items []string, msgResponse chan<- *imap.Message) (err error) { //nolint[funlen] + defer func() { + close(msgResponse) + if err != nil { + log.Errorf("cannot list messages (%v, %v, %v): %v", isUID, seqSet, items, err) + } + // Called from go-imap in goroutines - we need to handle panics for each function. + im.panicHandler.HandlePanic() + }() + + var markAsReadIDs []string + markAsReadMutex := &sync.Mutex{} + + l := log.WithField("cmd", "ListMessages") + + apiIDs, err := im.apiIDsFromSeqSet(isUID, seqSet) + if err != nil { + err = fmt.Errorf("list messages seq: %v", err) + l.WithField("seq", seqSet).Error(err) + return err + } + + // From RFC: UID range of 559:* always includes the UID of the last message + // in the mailbox, even if 559 is higher than any assigned UID value. + // See: https://tools.ietf.org/html/rfc3501#page-61 + if isUID && seqSet.Dynamic() && len(apiIDs) == 0 { + l.Debug("Requesting empty UID dynamic fetch, adding latest message") + apiID, err := im.storeMailbox.GetLatestAPIID() + if err != nil { + return nil + } + apiIDs = []string{apiID} + } + + input := make([]interface{}, len(apiIDs)) + for i, apiID := range apiIDs { + input[i] = apiID + } + + processCallback := func(value interface{}) (interface{}, error) { + apiID := value.(string) + + storeMessage, err := im.storeMailbox.GetMessage(apiID) + if err != nil { + err = fmt.Errorf("list message from db: %v", err) + l.WithField("apiID", apiID).Error(err) + return nil, err + } + + msg, err := im.getMessage(storeMessage, items) + if err != nil { + err = fmt.Errorf("list message build: %v", err) + l.WithField("metaID", storeMessage.ID()).Error(err) + return nil, err + } + + if storeMessage.Message().Unread == 1 { + for section := range msg.Body { + // Peek means get messages without marking them as read. + // If client does not only ask for peek, we have to mark them as read. + if !section.Peek { + markAsReadMutex.Lock() + markAsReadIDs = append(markAsReadIDs, storeMessage.ID()) + markAsReadMutex.Unlock() + msg.Flags = append(msg.Flags, imap.SeenFlag) + break + } + } + } + + return msg, nil + } + + collectCallback := func(idx int, value interface{}) error { + msg := value.(*imap.Message) + msgResponse <- msg + return nil + } + + err = parallel.RunParallel(fetchMessagesWorkers, input, processCallback, collectCallback) + if err != nil { + return err + } + + if len(markAsReadIDs) > 0 { + if err := im.storeMailbox.MarkMessagesRead(markAsReadIDs); err != nil { + l.Warnf("Cannot mark messages as read: %v", err) + } + } + return nil +} + +// apiIDsFromSeqSet takes an IMAP sequence set (which can contain either +// sequence numbers or UIDs) and returns all known API IDs in this range. +func (im *imapMailbox) apiIDsFromSeqSet(uid bool, seqSet *imap.SeqSet) ([]string, error) { + apiIDs := []string{} + for _, seq := range seqSet.Set { + var newAPIIDs []string + var err error + if uid { + newAPIIDs, err = im.storeMailbox.GetAPIIDsFromUIDRange(seq.Start, seq.Stop) + } else { + newAPIIDs, err = im.storeMailbox.GetAPIIDsFromSequenceRange(seq.Start, seq.Stop) + } + if err != nil { + return []string{}, err + } + apiIDs = append(apiIDs, newAPIIDs...) + } + if len(apiIDs) == 0 { + log.Debugf("Requested empty message list: %v %v", uid, seqSet) + } + return apiIDs, nil +} + +func isAddressInList(addrs []*mail.Address, query string) bool { //nolint[deadcode] + for _, addr := range addrs { + if strings.Contains(addr.Address, query) || strings.Contains(addr.Name, query) { + return true + } + } + return false +} + +func isStringInList(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false +} + +func addressMatch(addresses []*mail.Address, criteria string) bool { + for _, addr := range addresses { + if strings.Contains(strings.ToLower(addr.String()), strings.ToLower(criteria)) { + return true + } + } + return false +} + +func hasKeyword(m *pmapi.Message, keyword string) bool { + for _, v := range message.GetHeader(m) { + if strings.Contains(strings.ToLower(strings.Join(v, " ")), strings.ToLower(keyword)) { + return true + } + } + return false +} diff --git a/internal/imap/mailbox_root.go b/internal/imap/mailbox_root.go new file mode 100644 index 00000000..c86ddb96 --- /dev/null +++ b/internal/imap/mailbox_root.go @@ -0,0 +1,120 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package imap + +import ( + "errors" + "time" + + "github.com/ProtonMail/proton-bridge/internal/store" + + imap "github.com/emersion/go-imap" +) + +// The mailbox containing all custom folders or labels. +// The purpose of this mailbox is to see "Folders" and "Labels" +// at the root of the mailbox tree, e.g.: +// +// Folders << this +// Folders/Family +// +// Labels << this +// Labels/Security +// +// This mailbox cannot be modified or read in any way. +type imapRootMailbox struct { + isFolder bool +} + +func newFoldersRootMailbox() *imapRootMailbox { + return &imapRootMailbox{isFolder: true} +} + +func newLabelsRootMailbox() *imapRootMailbox { + return &imapRootMailbox{isFolder: false} +} + +func (m *imapRootMailbox) Name() string { + if m.isFolder { + return store.UserFoldersMailboxName + } + return store.UserLabelsMailboxName +} + +func (m *imapRootMailbox) Info() (info *imap.MailboxInfo, err error) { + info = &imap.MailboxInfo{ + Attributes: []string{imap.NoSelectAttr}, + Delimiter: store.PathDelimiter, + } + + if m.isFolder { + info.Name = store.UserFoldersMailboxName + } else { + info.Name = store.UserLabelsMailboxName + } + + return +} + +func (m *imapRootMailbox) Status(items []string) (status *imap.MailboxStatus, err error) { + status = &imap.MailboxStatus{} + if m.isFolder { + status.Name = store.UserFoldersMailboxName + } else { + status.Name = store.UserLabelsMailboxName + } + return +} + +func (m *imapRootMailbox) Subscribe() error { + return errors.New("cannot subscribe to Labels or Folders mailboxes") +} + +func (m *imapRootMailbox) Unsubscribe() error { + return errors.New("cannot unsubscribe from Labels or Folders mailboxes") +} + +func (m *imapRootMailbox) Check() error { + return nil +} + +func (m *imapRootMailbox) ListMessages(uid bool, seqset *imap.SeqSet, items []string, ch chan<- *imap.Message) error { + close(ch) + return nil +} + +func (m *imapRootMailbox) SearchMessages(uid bool, criteria *imap.SearchCriteria) (ids []uint32, err error) { + return +} + +func (m *imapRootMailbox) CreateMessage(flags []string, t time.Time, body imap.Literal) error { + return errors.New("cannot create a message in this mailbox") +} + +func (m *imapRootMailbox) UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, op imap.FlagsOp, flags []string) (err error) { + return errors.New("cannot update message flags in this mailbox") +} + +func (m *imapRootMailbox) CopyMessages(uid bool, seqset *imap.SeqSet, dest string) error { + return nil +} + +// Expunge is not used by Bridge. We delete the message once it is flagged as \Deleted. +func (m *imapRootMailbox) Expunge() error { + return nil +} diff --git a/internal/imap/server.go b/internal/imap/server.go new file mode 100644 index 00000000..e79790ae --- /dev/null +++ b/internal/imap/server.go @@ -0,0 +1,188 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package imap + +import ( + "crypto/tls" + "fmt" + "io" + "strings" + "time" + + imapid "github.com/ProtonMail/go-imap-id" + "github.com/ProtonMail/proton-bridge/internal/bridge" + "github.com/ProtonMail/proton-bridge/internal/events" + "github.com/ProtonMail/proton-bridge/internal/imap/uidplus" + "github.com/ProtonMail/proton-bridge/pkg/listener" + "github.com/emersion/go-imap" + imapappendlimit "github.com/emersion/go-imap-appendlimit" + imapidle "github.com/emersion/go-imap-idle" + imapquota "github.com/emersion/go-imap-quota" + imapspecialuse "github.com/emersion/go-imap-specialuse" + imapserver "github.com/emersion/go-imap/server" + "github.com/emersion/go-sasl" + "github.com/sirupsen/logrus" +) + +type imapServer struct { + server *imapserver.Server + eventListener listener.Listener +} + +// NewIMAPServer constructs a new IMAP server configured with the given options. +func NewIMAPServer(debugClient, debugServer bool, port int, tls *tls.Config, imapBackend *imapBackend, eventListener listener.Listener) *imapServer { //nolint[golint] + s := imapserver.New(imapBackend) + s.Addr = fmt.Sprintf("%v:%v", bridge.Host, port) + s.TLSConfig = tls + s.AllowInsecureAuth = true + s.ErrorLog = newServerErrorLogger("server-imap") + s.AutoLogout = 30 * time.Minute + + if debugClient || debugServer { + var localDebug, remoteDebug imap.WriterWithFields + if debugClient { + remoteDebug = &logWithFields{log: log.WithField("pkg", "imap/client"), fields: logrus.Fields{}} + } + if debugServer { + localDebug = &logWithFields{log: log.WithField("pkg", "imap/server"), fields: logrus.Fields{}} + } + s.Debug = imap.NewDebugWithFields(localDebug, remoteDebug) + } + + serverID := imapid.ID{ + imapid.FieldName: "ProtonMail", + imapid.FieldVendor: "Proton Technologies AG", + imapid.FieldSupportURL: "https://protonmail.com/support", + } + + s.EnableAuth(sasl.Login, func(conn imapserver.Conn) sasl.Server { + conn.Server().ForEachConn(func(candidate imapserver.Conn) { + if id, ok := candidate.(imapid.Conn); ok { + if conn.Context() == candidate.Context() { + imapBackend.setLastMailClient(id.ID()) + return + } + } + }) + + return sasl.NewLoginServer(func(address, password string) error { + user, err := conn.Server().Backend.Login(address, password) + if err != nil { + return err + } + + ctx := conn.Context() + ctx.State = imap.AuthenticatedState + ctx.User = user + return nil + }) + }) + + s.Enable( + imapidle.NewExtension(), + //imapmove.NewExtension(), // extension is not fully implemented: if UIDPLUS exists it MUST return COPYUID and EXPUNGE continuous responses + imapspecialuse.NewExtension(), + imapid.NewExtension(serverID), + imapquota.NewExtension(), + imapappendlimit.NewExtension(), + uidplus.NewExtension(), + ) + + return &imapServer{ + server: s, + eventListener: eventListener, + } +} + +// Starts the server. +func (s *imapServer) ListenAndServe() { + go s.monitorDisconnectedUsers() + + log.Info("IMAP server listening at ", s.server.Addr) + err := s.server.ListenAndServe() + if err != nil { + s.eventListener.Emit(events.ErrorEvent, "IMAP failed: "+err.Error()) + log.Error("IMAP failed: ", err) + return + } + defer s.server.Close() //nolint[errcheck] + + log.Info("IMAP server stopped") +} + +// Stops the server. +func (s *imapServer) Close() { + _ = s.server.Close() +} + +func (s *imapServer) monitorDisconnectedUsers() { + ch := make(chan string) + s.eventListener.Add(events.CloseConnectionEvent, ch) + + for address := range ch { + address := address + log.Info("Disconnecting all open IMAP connections for ", address) + disconnectUser := func(conn imapserver.Conn) { + connUser := conn.Context().User + if connUser != nil && strings.EqualFold(connUser.Username(), address) { + _ = conn.Close() + } + } + s.server.ForEachConn(disconnectUser) + } +} + +// logWithFields is used for debuging with additional field. +type logWithFields struct { + log *logrus.Entry + fields logrus.Fields +} + +func (lf *logWithFields) Writer() io.Writer { + w := lf.log.WithFields(lf.fields).WriterLevel(logrus.DebugLevel) + lf.fields = logrus.Fields{} + return w +} + +func (lf *logWithFields) SetField(key, value string) { + lf.fields[key] = value +} + +// serverErrorLogger implements go-imap/logger interface. +type serverErrorLogger struct { + tag string +} + +func newServerErrorLogger(tag string) *serverErrorLogger { + return &serverErrorLogger{tag} +} + +func (s *serverErrorLogger) CheckErrorForReport(serverErr string) { +} + +func (s *serverErrorLogger) Printf(format string, args ...interface{}) { + err := fmt.Sprintf(format, args...) + s.CheckErrorForReport(err) + log.WithField("pkg", s.tag).Error(err) +} + +func (s *serverErrorLogger) Println(args ...interface{}) { + err := fmt.Sprintln(args...) + s.CheckErrorForReport(err) + log.WithField("pkg", s.tag).Error(err) +} diff --git a/internal/imap/store.go b/internal/imap/store.go new file mode 100644 index 00000000..ee63908c --- /dev/null +++ b/internal/imap/store.go @@ -0,0 +1,156 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package imap + +import ( + "io" + "net/mail" + + pmcrypto "github.com/ProtonMail/gopenpgp/crypto" + "github.com/ProtonMail/proton-bridge/internal/imap/uidplus" + "github.com/ProtonMail/proton-bridge/internal/store" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" +) + +type storeUserProvider interface { + UserID() string + GetSpace() (usedSpace, maxSpace uint, err error) + GetMaxUpload() (uint, error) + + GetAddress(addressID string) (storeAddressProvider, error) + + CreateDraft( + kr *pmcrypto.KeyRing, + message *pmapi.Message, + attachmentReaders []io.Reader, + attachedPublicKey, + attachedPublicKeyName string, + parentID string) (*pmapi.Message, []*pmapi.Attachment, error) +} + +type storeAddressProvider interface { + AddressString() string + AddressID() string + APIAddress() *pmapi.Address + + CreateMailbox(name string) error + ListMailboxes() []storeMailboxProvider + GetMailbox(name string) (storeMailboxProvider, error) +} + +type storeMailboxProvider interface { + LabelID() string + Name() string + Color() string + IsSystem() bool + IsFolder() bool + UIDValidity() uint32 + + Rename(newName string) error + Delete() error + + GetAPIIDsFromUIDRange(start, stop uint32) ([]string, error) + GetAPIIDsFromSequenceRange(start, stop uint32) ([]string, error) + GetLatestAPIID() (string, error) + GetNextUID() (uint32, error) + GetCounts() (dbTotal, dbUnread uint, err error) + GetUIDList(apiIDs []string) *uidplus.OrderedSeq + GetUIDByHeader(header *mail.Header) uint32 + GetDelimiter() string + + GetMessage(apiID string) (storeMessageProvider, error) + FetchMessage(apiID string) (storeMessageProvider, error) + LabelMessages(apiID []string) error + UnlabelMessages(apiID []string) error + MarkMessagesRead(apiID []string) error + MarkMessagesUnread(apiID []string) error + MarkMessagesStarred(apiID []string) error + MarkMessagesUnstarred(apiID []string) error + ImportMessage(msg *pmapi.Message, body []byte, labelIDs []string) error + DeleteMessages(apiID []string) error +} + +type storeMessageProvider interface { + ID() string + UID() (uint32, error) + SequenceNumber() (uint32, error) + Message() *pmapi.Message + + SetSize(int64) error + SetContentTypeAndHeader(string, mail.Header) error +} + +type storeUserWrap struct { + *store.Store +} + +// newStoreUserWrap wraps store struct into local storeUserWrap to implement local +// interface. The problem is that store returns the store package's Address type, so +// every method that returns an address has to be overridden to fulfill the interface. +// The same is true for other store structs i.e. storeAddress or storeMailbox. +func newStoreUserWrap(store *store.Store) *storeUserWrap { + return &storeUserWrap{Store: store} +} + +func (s *storeUserWrap) GetAddress(addressID string) (storeAddressProvider, error) { + address, err := s.Store.GetAddress(addressID) + if err != nil { + return nil, err + } + return newStoreAddressWrap(address), nil +} + +type storeAddressWrap struct { + *store.Address +} + +func newStoreAddressWrap(address *store.Address) *storeAddressWrap { + return &storeAddressWrap{Address: address} +} + +func (s *storeAddressWrap) ListMailboxes() []storeMailboxProvider { + mailboxes := []storeMailboxProvider{} + for _, mailbox := range s.Address.ListMailboxes() { + mailboxes = append(mailboxes, newStoreMailboxWrap(mailbox)) + } + return mailboxes +} + +func (s *storeAddressWrap) GetMailbox(name string) (storeMailboxProvider, error) { + mailbox, err := s.Address.GetMailbox(name) + if err != nil { + return nil, err + } + return newStoreMailboxWrap(mailbox), nil +} + +type storeMailboxWrap struct { + *store.Mailbox +} + +func newStoreMailboxWrap(mailbox *store.Mailbox) *storeMailboxWrap { + return &storeMailboxWrap{Mailbox: mailbox} +} + +func (s *storeMailboxWrap) GetMessage(apiID string) (storeMessageProvider, error) { + return s.Mailbox.GetMessage(apiID) +} + +func (s *storeMailboxWrap) FetchMessage(apiID string) (storeMessageProvider, error) { + return s.Mailbox.FetchMessage(apiID) +} diff --git a/internal/imap/uidplus/extension.go b/internal/imap/uidplus/extension.go new file mode 100644 index 00000000..679531dc --- /dev/null +++ b/internal/imap/uidplus/extension.go @@ -0,0 +1,198 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Package uidplus DOES NOT implement full RFC4315! +// +// Excluded parts are: +// * Response `UIDNOTSTICKY`: All mailboxes of Bridge support stable +// UIDVALIDITY so it would never return this response +// +// Otherwise the standard RFC4315 is followed. +package uidplus + +import ( + "fmt" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/server" + "github.com/sirupsen/logrus" +) + +// Capability extension identifier +const Capability = "UIDPLUS" + +const ( + copyuid = "COPYUID" + appenduid = "APPENDUID" + copySuccess = "COPY successful" + appendSucess = "APPEND successful" +) + +var log = logrus.WithField("pkg", "impa/uidplus") //nolint[gochecknoglobals] + +// OrderedSeq to remember Seq in order they are added. +// We didn't find any restriction in RFC that server must respond with ranges +// so we decided to always do explicit list. This makes sure that no dynamic +// ranges or out of the bound ranges are possible. +// +// NOTE: potential issue with response length +// * the user selects large number of messages to be copied and the +// response line will be long, +// * list of UIDs which high values +// which can create long response line. We didn't find a maximum length of one +// IMAP response line or maximum length of IMAP "response code" with parameters. +type OrderedSeq []uint32 + +// Len return number of added seq numbers. +func (os OrderedSeq) Len() int { return len(os) } + +// Add number to sequence. Zero is not acceptable UID and it won't be added to list. +func (os *OrderedSeq) Add(num uint32) { + if num == 0 { + return + } + *os = append(*os, num) +} + +func (os *OrderedSeq) String() string { + out := "" + if len(*os) == 0 { + return out + } + + lastS := uint32(0) + isRangeOpened := false + for i, s := range *os { + // write first + if i == 0 { + out += fmt.Sprintf("%d", s) + isRangeOpened = false + lastS = s + continue + } + + isLast := (i == len(*os)-1) + isContinuous := (lastS+1 == s) + + if isContinuous { + isRangeOpened = true + lastS = s + if isLast { + out += fmt.Sprintf(":%d", s) + } + continue + } + + if isRangeOpened && !isContinuous { // close range + out += fmt.Sprintf(":%d,%d", lastS, s) + isRangeOpened = false + lastS = s + continue + } + + // Range is not opened and it is not continuous. + out += fmt.Sprintf(",%d", s) + isRangeOpened = false + lastS = s + } + + return out +} + +// UIDExpunge implements server.Handler but has no effect because Bridge is not +// using EXPUNGE at all. The message is deleted right after it was flagged as +// \Deleted Bridge should simply ignore this command with empty `OK` response. +// +// If not implemented it would cause harmless IMAP error. +// +// This overrides the standard EXPUNGE functionality. +type UIDExpunge struct{} + +func (e *UIDExpunge) Parse(fields []interface{}) error { log.Traceln("parse", fields); return nil } +func (e *UIDExpunge) Handle(conn server.Conn) error { log.Traceln("handle"); return nil } +func (e *UIDExpunge) UidHandle(conn server.Conn) error { log.Traceln("uid handle"); return nil } //nolint[golint] + +type extension struct{} + +// NewExtension of UIDPLUS. +func NewExtension() server.Extension { + return &extension{} +} + +func (ext *extension) Capabilities(c server.Conn) []string { + if c.Context().State&imap.AuthenticatedState != 0 { + return []string{Capability} + } + return nil +} + +func (ext *extension) Command(name string) server.HandlerFactory { + if name == imap.Expunge { + return func() server.Handler { + return &UIDExpunge{} + } + } + + return nil +} + +func getStatusResponseCopy(uidValidity uint32, sourceSeq, targetSeq *OrderedSeq) *imap.StatusResp { + info := copySuccess + + if sourceSeq.Len() != 0 && targetSeq.Len() != 0 && + sourceSeq.Len() == targetSeq.Len() { + info = fmt.Sprintf("[%s %d %s %s] %s", + copyuid, + uidValidity, + sourceSeq.String(), + targetSeq.String(), + copySuccess, + ) + } + + return &imap.StatusResp{ + Type: imap.StatusOk, + Info: info, + } +} + +// CopyResponse prepares OK response with extended UID information about copied message. +func CopyResponse(uidValidity uint32, sourceSeq, targetSeq *OrderedSeq) error { + return server.ErrStatusResp(getStatusResponseCopy(uidValidity, sourceSeq, targetSeq)) +} + +func getStatusResponseAppend(uidValidity uint32, targetSeq *OrderedSeq) *imap.StatusResp { + info := appendSucess + if targetSeq.Len() > 0 { + info = fmt.Sprintf("[%s %d %s] %s", + appenduid, + uidValidity, + targetSeq.String(), + appendSucess, + ) + } + + return &imap.StatusResp{ + Type: imap.StatusOk, + Info: info, + } +} + +// AppendResponse prepares OK response with extended UID information about appended message. +func AppendResponse(uidValidity uint32, targetSeq *OrderedSeq) error { + return server.ErrStatusResp(getStatusResponseAppend(uidValidity, targetSeq)) +} diff --git a/internal/imap/uidplus/extension_test.go b/internal/imap/uidplus/extension_test.go new file mode 100644 index 00000000..36fa7625 --- /dev/null +++ b/internal/imap/uidplus/extension_test.go @@ -0,0 +1,108 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package uidplus + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// uidValidity is constant and global for bridge IMAP. +const uidValidity = 66 + +type testResponseData struct { + sourceList, targetList []int + expCopyInfo, expAppendInfo string +} + +func (td *testResponseData) getOrdSeqFromList(seqList []int) *OrderedSeq { + set := &OrderedSeq{} + for _, seq := range seqList { + set.Add(uint32(seq)) + } + return set +} + +func (td *testResponseData) testCopyAndAppendResponses(tb testing.TB) { + sourceSeq := td.getOrdSeqFromList(td.sourceList) + targetSeq := td.getOrdSeqFromList(td.targetList) + + gotCopyResp := getStatusResponseCopy(uidValidity, sourceSeq, targetSeq) + assert.Equal(tb, td.expCopyInfo, gotCopyResp.Info, "source: %v\ntarget: %v", td.sourceList, td.targetList) + + gotAppendResp := getStatusResponseAppend(uidValidity, targetSeq) + assert.Equal(tb, td.expAppendInfo, gotAppendResp.Info, "source: %v\ntarget: %v", td.sourceList, td.targetList) +} + +func TestStatusResponseInfo(t *testing.T) { + testData := []*testResponseData{ + { // Dynamic range must never be returned e.g 4:* (explicitly true if you OrderedSeq used instead of imap.SeqSet). + sourceList: []int{4, 5, 6}, + targetList: []int{1, 2, 3}, + expCopyInfo: "[" + copyuid + " 66 4:6 1:3] " + copySuccess, + expAppendInfo: "[" + appenduid + " 66 1:3] " + appendSucess, + }, + { // Ranges can be used only for consecutive strictly rising sequence. + sourceList: []int{6, 7, 8, 9, 10, 1, 3, 5, 10, 11, 20, 21, 30, 31}, + targetList: []int{1, 2, 3, 4, 50, 8, 7, 6, 12, 13, 22, 23, 32, 33}, + expCopyInfo: "[" + copyuid + " 66 6:10,1,3,5,10:11,20:21,30:31 1:4,50,8,7,6,12:13,22:23,32:33] " + copySuccess, + expAppendInfo: "[" + appenduid + " 66 1:4,50,8,7,6,12:13,22:23,32:33] " + appendSucess, + }, + { // Keep order (cannot use sequence set because 3,2,1 equals 1,2,3 equals 1:3 equals 3:1). + sourceList: []int{4, 5, 8}, + targetList: []int{3, 2, 1}, + expCopyInfo: "[" + copyuid + " 66 4:5,8 3,2,1] " + copySuccess, + expAppendInfo: "[" + appenduid + " 66 3,2,1] " + appendSucess, + }, + { // Incorrect count of source and target uids is wrong and we should not report it. + sourceList: []int{1}, + targetList: []int{1, 2, 3}, + expCopyInfo: copySuccess, + expAppendInfo: "[" + appenduid + " 66 1:3] " + appendSucess, + }, + { + sourceList: []int{1, 2, 3}, + targetList: []int{1}, + expCopyInfo: copySuccess, + expAppendInfo: "[" + appenduid + " 66 1] " + appendSucess, + }, + { // One item should be always interpreted as one number (don't use imap.SeqSet because 1:1 means 1). + sourceList: []int{1}, + targetList: []int{1}, + expCopyInfo: "[" + copyuid + " 66 1 1] " + copySuccess, + expAppendInfo: "[" + appenduid + " 66 1] " + appendSucess, + }, + { // No UID is wrong we should not report it. + sourceList: []int{1}, + targetList: []int{}, + expCopyInfo: copySuccess, + expAppendInfo: appendSucess, + }, + { // Duplicates should be reported as list. + sourceList: []int{1, 1, 1}, + targetList: []int{6, 6, 6}, + expCopyInfo: "[" + copyuid + " 66 1,1,1 6,6,6] " + copySuccess, + expAppendInfo: "[" + appenduid + " 66 6,6,6] " + appendSucess, + }, + } + + for _, td := range testData { + td.testCopyAndAppendResponses(t) + } +} diff --git a/internal/imap/user.go b/internal/imap/user.go new file mode 100644 index 00000000..70abae49 --- /dev/null +++ b/internal/imap/user.go @@ -0,0 +1,239 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package imap + +import ( + "errors" + "strings" + + "github.com/ProtonMail/proton-bridge/internal/bridge" + imapquota "github.com/emersion/go-imap-quota" + goIMAPBackend "github.com/emersion/go-imap/backend" +) + +var ( + errNoSuchMailbox = errors.New("no such mailbox") //nolint[gochecknoglobals] +) + +type imapUser struct { + panicHandler panicHandler + backend *imapBackend + user bridgeUser + client bridge.PMAPIProvider + + storeUser storeUserProvider + storeAddress storeAddressProvider + + currentAddressLowercase string +} + +// newIMAPUser returns struct implementing go-imap/user interface. +func newIMAPUser( + panicHandler panicHandler, + backend *imapBackend, + user bridgeUser, + addressID, address string, +) (*imapUser, error) { + log.WithField("address", addressID).Debug("Creating new IMAP user") + + storeUser := user.GetStore() + if storeUser == nil { + return nil, errors.New("user database is not initialized") + } + + storeAddress, err := storeUser.GetAddress(addressID) + if err != nil { + log.WithField("address", addressID).Debug("Could not get store user address") + return nil, err + } + + client := user.GetTemporaryPMAPIClient() + + return &imapUser{ + panicHandler: panicHandler, + backend: backend, + user: user, + client: client, + + storeUser: storeUser, + storeAddress: storeAddress, + + currentAddressLowercase: strings.ToLower(address), + }, err +} + +func (iu *imapUser) isSubscribed(labelID string) bool { + subscriptionExceptions := iu.backend.getCacheList(iu.storeUser.UserID(), SubscriptionException) + exceptions := strings.Split(subscriptionExceptions, ";") + + for _, exception := range exceptions { + if exception == labelID { + return false + } + } + return true +} + +func (iu *imapUser) removeFromCache(label, value string) { + iu.backend.removeFromCache(iu.storeUser.UserID(), label, value) +} + +func (iu *imapUser) addToCache(label, value string) { + iu.backend.addToCache(iu.storeUser.UserID(), label, value) +} + +// Username returns this user's username. +func (iu *imapUser) Username() string { + // Called from go-imap in goroutines - we need to handle panics for each function. + defer iu.panicHandler.HandlePanic() + + return iu.storeAddress.AddressString() +} + +// ListMailboxes returns a list of mailboxes belonging to this user. +// If subscribed is set to true, returns only subscribed mailboxes. +func (iu *imapUser) ListMailboxes(showOnlySubcribed bool) ([]goIMAPBackend.Mailbox, error) { + // Called from go-imap in goroutines - we need to handle panics for each function. + defer iu.panicHandler.HandlePanic() + + mailboxes := []goIMAPBackend.Mailbox{} + for _, storeMailbox := range iu.storeAddress.ListMailboxes() { + if showOnlySubcribed && !iu.isSubscribed(storeMailbox.LabelID()) { + continue + } + mailbox := newIMAPMailbox(iu.panicHandler, iu, storeMailbox) + mailboxes = append(mailboxes, mailbox) + } + + mailboxes = append(mailboxes, newLabelsRootMailbox()) + mailboxes = append(mailboxes, newFoldersRootMailbox()) + + log.WithField("mailboxes", mailboxes).Trace("Listing mailboxes") + + return mailboxes, nil +} + +// GetMailbox returns a mailbox. If it doesn't exist, it returns ErrNoSuchMailbox. +func (iu *imapUser) GetMailbox(name string) (mb goIMAPBackend.Mailbox, err error) { + // Called from go-imap in goroutines - we need to handle panics for each function. + defer iu.panicHandler.HandlePanic() + + storeMailbox, err := iu.storeAddress.GetMailbox(name) + if err != nil { + log.WithField("name", name).WithError(err).Error("Could not get mailbox") + return + } + + return newIMAPMailbox(iu.panicHandler, iu, storeMailbox), nil +} + +// CreateMailbox creates a new mailbox. +func (iu *imapUser) CreateMailbox(name string) error { + // Called from go-imap in goroutines - we need to handle panics for each function. + defer iu.panicHandler.HandlePanic() + + return iu.storeAddress.CreateMailbox(name) +} + +// DeleteMailbox permanently removes the mailbox with the given name. +func (iu *imapUser) DeleteMailbox(name string) (err error) { + // Called from go-imap in goroutines - we need to handle panics for each function. + defer iu.panicHandler.HandlePanic() + + storeMailbox, err := iu.storeAddress.GetMailbox(name) + if err != nil { + log.WithField("name", name).WithError(err).Error("Could not get mailbox") + return + } + + return storeMailbox.Delete() +} + +// RenameMailbox changes the name of a mailbox. It is an error to attempt to +// rename a mailbox that does not exist or to rename a mailbox to a name that +// already exists. +func (iu *imapUser) RenameMailbox(oldName, newName string) (err error) { + // Called from go-imap in goroutines - we need to handle panics for each function. + defer iu.panicHandler.HandlePanic() + + storeMailbox, err := iu.storeAddress.GetMailbox(oldName) + if err != nil { + log.WithField("name", oldName).WithError(err).Error("Could not get mailbox") + return + } + + return storeMailbox.Rename(newName) +} + +// Logout is called when this User will no longer be used, likely because the +// client closed the connection. +func (iu *imapUser) Logout() (err error) { + // Called from go-imap in goroutines - we need to handle panics for each function. + defer iu.panicHandler.HandlePanic() + + log.Debug("IMAP client logged out address ", iu.storeAddress.AddressID()) + + iu.backend.deleteUser(iu.currentAddressLowercase) + + return nil +} + +func (iu *imapUser) GetQuota(name string) (*imapquota.Status, error) { + // Called from go-imap in goroutines - we need to handle panics for each function. + defer iu.panicHandler.HandlePanic() + + usedSpace, maxSpace, err := iu.storeUser.GetSpace() + if err != nil { + log.Error("Failed getting quota: ", err) + return nil, err + } + + resources := make(map[string][2]uint32) + var list [2]uint32 + list[0] = uint32(usedSpace / 1000) + list[1] = uint32(maxSpace / 1000) + resources[imapquota.ResourceStorage] = list + status := &imapquota.Status{ + Name: "", + Resources: resources, + } + + return status, nil +} + +func (iu *imapUser) SetQuota(name string, resources map[string]uint32) error { + // Called from go-imap in goroutines - we need to handle panics for each function. + defer iu.panicHandler.HandlePanic() + + return errors.New("quota cannot be set") +} + +func (iu *imapUser) CreateMessageLimit() *uint32 { + // Called from go-imap in goroutines - we need to handle panics for each function. + defer iu.panicHandler.HandlePanic() + + maxUpload, err := iu.storeUser.GetMaxUpload() + if err != nil { + log.Error("Failed getting current user for message limit: ", err) + zero := uint32(0) + return &zero + } + + upload := uint32(maxUpload) + return &upload +} diff --git a/internal/imap/utils.go b/internal/imap/utils.go new file mode 100644 index 00000000..4cf75966 --- /dev/null +++ b/internal/imap/utils.go @@ -0,0 +1,32 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package imap + +import ( + "io" + "net/http" + "net/textproto" +) + +func writeHeader(w io.Writer, h textproto.MIMEHeader) (err error) { + if err = http.Header(h).Write(w); err != nil { + return + } + _, err = io.WriteString(w, "\r\n") + return +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go new file mode 100644 index 00000000..b18bf5ac --- /dev/null +++ b/internal/metrics/metrics.go @@ -0,0 +1,70 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Package metrics collects string constants used to report anonymous usage metrics. +package metrics + +type ( + Category string + Action string + Label string +) + +// Metric represents a single metric that can be reported and contains the necessary fields +// of category, action and label that the /metrics endpoint expects. +type Metric struct { + c Category + a Action + l Label +} + +// New returns a metric struct with the given category, action and label. +// Maybe in future we could perform checks here that the correct category is given for each action. +// That's why the Metric fields are not exported; we don't want users creating broken metrics +// (though for now they still can do that). +func New(c Category, a Action, l Label) Metric { + return Metric{c: c, a: a, l: l} +} + +// Get returns the category, action and label of a metric. +func (m Metric) Get() (Category, Action, Label) { + return m.c, m.a, m.l +} + +// Metrics related to bridge/account setup. +const ( + // Setup is used to group metrics related to bridge setup e.g. first start, new user. + Setup = Category("setup") + + // FirstStart signifies that the bridge has been started for the first time on a user's + // machine (or at least, no config directory was found). + FirstStart = Action("first_start") + + // NewUser signifies a new user account has been added to the bridge. + NewUser = Action("new_user") +) + +// Metrics related to heartbeats of various kinds. +const ( + // Heartbeat is used to group heartbeat metrics e.g. the daily alive signal. + Heartbeat = Category("heartbeat") + + // Daily is a daily signal that indicates continued bridge usage. + Daily = Action("daily") +) + +const NoLabel = Label("") diff --git a/internal/pmapifactory/pmapi_noprod.go b/internal/pmapifactory/pmapi_noprod.go new file mode 100644 index 00000000..c30a9b52 --- /dev/null +++ b/internal/pmapifactory/pmapi_noprod.go @@ -0,0 +1,33 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build !pmapi_prod + +// Package pmapifactory creates pmapi client instances. +package pmapifactory + +import ( + "github.com/ProtonMail/proton-bridge/internal/bridge" + "github.com/ProtonMail/proton-bridge/pkg/listener" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" +) + +func New(cfg bridge.Configer, _ listener.Listener) bridge.PMAPIProviderFactory { + return func(userID string) bridge.PMAPIProvider { + return pmapi.NewClient(cfg.GetAPIConfig(), userID) + } +} diff --git a/internal/pmapifactory/pmapi_prod.go b/internal/pmapifactory/pmapi_prod.go new file mode 100644 index 00000000..bc077404 --- /dev/null +++ b/internal/pmapifactory/pmapi_prod.go @@ -0,0 +1,54 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build pmapi_prod + +// Package pmapifactory creates pmapi client instances. +package pmapifactory + +import ( + "time" + + "github.com/ProtonMail/proton-bridge/internal/bridge" + "github.com/ProtonMail/proton-bridge/internal/events" + "github.com/ProtonMail/proton-bridge/pkg/listener" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" +) + +func New(config bridge.Configer, listener listener.Listener) bridge.PMAPIProviderFactory { + cfg := config.GetAPIConfig() + + pin := pmapi.NewPMAPIPinning(cfg.AppVersion) + pin.ReportCertIssueLocal = func() { + listener.Emit(events.TLSCertIssue, "") + } + + // This transport already has timeouts set governing the roundtrip: + // - IdleConnTimeout: 5 * time.Minute, + // - ExpectContinueTimeout: 500 * time.Millisecond, + // - ResponseHeaderTimeout: 30 * time.Second, + cfg.Transport = pin.TransportWithPinning() + + // We set additional timeouts/thresholds for the request as a whole: + cfg.Timeout = 10 * time.Minute // Overall request timeout (~25MB / 10 mins => ~40kB/s, should be reasonable). + cfg.FirstReadTimeout = 30 * time.Second // 30s to match 30s response header timeout. + cfg.MinSpeed = 1 << 13 // Enforce minimum download speed of 8kB/s. + + return func(userID string) bridge.PMAPIProvider { + return pmapi.NewClient(cfg, userID) + } +} diff --git a/internal/preferences/preferences.go b/internal/preferences/preferences.go new file mode 100644 index 00000000..40ea13ea --- /dev/null +++ b/internal/preferences/preferences.go @@ -0,0 +1,78 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Package preferences provides key names and defaults for preferences used in Bridge. +package preferences + +import ( + "strconv" + "time" + + "github.com/ProtonMail/proton-bridge/pkg/config" + "github.com/sirupsen/logrus" +) + +// Keys of preferences in JSON file. +const ( + FirstStartKey = "first_time_start" + NextHeartbeatKey = "next_heartbeat" + APIPortKey = "user_port_api" + IMAPPortKey = "user_port_imap" + SMTPPortKey = "user_port_smtp" + SMTPSSLKey = "user_ssl_smtp" + AllowProxyKey = "allow_proxy" + AutostartKey = "autostart" + ReportOutgoingNoEncKey = "report_outgoing_email_without_encryption" + LastVersionKey = "last_used_version" +) + +type configProvider interface { + GetPreferencesPath() string + GetDefaultAPIPort() int + GetDefaultIMAPPort() int + GetDefaultSMTPPort() int +} + +var ( + log = logrus.WithField("pkg", "store") //nolint[gochecknoglobals] +) + +// New returns loaded preferences with Bridge defaults when values are not set yet. +func New(cfg configProvider) (pref *config.Preferences) { + path := cfg.GetPreferencesPath() + pref = config.NewPreferences(path) + setDefaults(pref, cfg) + + log.WithField("path", path).Trace("Opened preferences") + + return +} + +func setDefaults(preferences *config.Preferences, cfg configProvider) { + preferences.SetDefault(FirstStartKey, "true") + preferences.SetDefault(NextHeartbeatKey, strconv.FormatInt(time.Now().Unix(), 10)) + preferences.SetDefault(APIPortKey, strconv.Itoa(cfg.GetDefaultAPIPort())) + preferences.SetDefault(IMAPPortKey, strconv.Itoa(cfg.GetDefaultIMAPPort())) + preferences.SetDefault(SMTPPortKey, strconv.Itoa(cfg.GetDefaultSMTPPort())) + preferences.SetDefault(AllowProxyKey, "true") + preferences.SetDefault(AutostartKey, "true") + preferences.SetDefault(ReportOutgoingNoEncKey, "false") + preferences.SetDefault(LastVersionKey, "") + + // By default, stick to STARTTLS. If the user uses catalina+applemail they'll have to change to SSL. + preferences.SetDefault(SMTPSSLKey, "false") +} diff --git a/internal/smtp/backend.go b/internal/smtp/backend.go new file mode 100644 index 00000000..abf06004 --- /dev/null +++ b/internal/smtp/backend.go @@ -0,0 +1,109 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package smtp + +import ( + "strings" + "time" + + "github.com/ProtonMail/proton-bridge/internal/bridge" + "github.com/ProtonMail/proton-bridge/internal/preferences" + "github.com/ProtonMail/proton-bridge/pkg/config" + "github.com/ProtonMail/proton-bridge/pkg/listener" + goSMTPBackend "github.com/emersion/go-smtp" +) + +type panicHandler interface { + HandlePanic() +} + +type smtpBackend struct { + panicHandler panicHandler + eventListener listener.Listener + preferences *config.Preferences + bridge bridger + shouldSendNoEncChannels map[string]chan bool + sendRecorder *sendRecorder +} + +// NewSMTPBackend returns struct implementing go-smtp/backend interface. +func NewSMTPBackend( + panicHandler panicHandler, + eventListener listener.Listener, + preferences *config.Preferences, + bridge *bridge.Bridge, +) *smtpBackend { //nolint[golint] + return newSMTPBackend(panicHandler, eventListener, preferences, newBridgeWrap(bridge)) +} + +func newSMTPBackend( + panicHandler panicHandler, + eventListener listener.Listener, + preferences *config.Preferences, + bridge bridger, +) *smtpBackend { + return &smtpBackend{ + panicHandler: panicHandler, + eventListener: eventListener, + preferences: preferences, + bridge: bridge, + shouldSendNoEncChannels: make(map[string]chan bool), + sendRecorder: newSendRecorder(), + } +} + +// Login authenticates a user. +func (sb *smtpBackend) Login(username, password string) (goSMTPBackend.User, error) { + // Called from go-smtp in goroutines - we need to handle panics for each function. + defer sb.panicHandler.HandlePanic() + username = strings.ToLower(username) + + user, err := sb.bridge.GetUser(username) + if err != nil { + log.Warn("Cannot get user: ", err) + return nil, err + } + if err := user.CheckBridgeLogin(password); err != nil { + log.WithError(err).Error("Could not check bridge password") + // Apple Mail sometimes generates a lot of requests very quickly. It's good practice + // to have a timeout after bad logins so that we can slow those requests down a little bit. + time.Sleep(10 * time.Second) + return nil, err + } + // Client can log in only using address so we can properly close all SMTP connections. + addressID, err := user.GetAddressID(username) + if err != nil { + log.Error("Cannot get addressID: ", err) + return nil, err + } + // AddressID is only for split mode--it has to be empty for combined mode. + if user.IsCombinedAddressMode() { + addressID = "" + } + return newSMTPUser(sb.panicHandler, sb.eventListener, sb, user, addressID) +} + +func (sb *smtpBackend) shouldReportOutgoingNoEnc() bool { + return sb.preferences.GetBool(preferences.ReportOutgoingNoEncKey) +} + +func (sb *smtpBackend) ConfirmNoEncryption(messageID string, shouldSend bool) { + if ch, ok := sb.shouldSendNoEncChannels[messageID]; ok { + ch <- shouldSend + } +} diff --git a/internal/smtp/bridge.go b/internal/smtp/bridge.go new file mode 100644 index 00000000..e52fbf60 --- /dev/null +++ b/internal/smtp/bridge.go @@ -0,0 +1,65 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package smtp + +import ( + "github.com/ProtonMail/proton-bridge/internal/bridge" +) + +type bridger interface { + GetUser(query string) (bridgeUser, error) +} + +type bridgeUser interface { + CheckBridgeLogin(password string) error + IsCombinedAddressMode() bool + GetAddressID(address string) (string, error) + GetTemporaryPMAPIClient() bridge.PMAPIProvider + GetStore() storeUserProvider +} + +type bridgeWrap struct { + *bridge.Bridge +} + +// newBridgeWrap wraps bridge struct into local bridgeWrap to implement local +// interface. The problem is that bridge returns package bridge's User type, so +// every method that returns User has to be overridden to fulfill the interface. +func newBridgeWrap(bridge *bridge.Bridge) *bridgeWrap { + return &bridgeWrap{Bridge: bridge} +} + +func (b *bridgeWrap) GetUser(query string) (bridgeUser, error) { + user, err := b.Bridge.GetUser(query) + if err != nil { + return nil, err + } + return newBridgeUserWrap(user), nil +} + +type bridgeUserWrap struct { + *bridge.User +} + +func newBridgeUserWrap(bridgeUser *bridge.User) *bridgeUserWrap { + return &bridgeUserWrap{User: bridgeUser} +} + +func (u *bridgeUserWrap) GetStore() storeUserProvider { + return u.User.GetStore() +} diff --git a/internal/smtp/send_recorder.go b/internal/smtp/send_recorder.go new file mode 100644 index 00000000..f891700d --- /dev/null +++ b/internal/smtp/send_recorder.go @@ -0,0 +1,124 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package smtp + +import ( + "crypto/sha256" + "fmt" + "sync" + "time" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" +) + +type messageGetter interface { + GetMessage(string) (*pmapi.Message, error) +} + +type sendRecorderValue struct { + messageID string + time time.Time +} + +type sendRecorder struct { + lock *sync.RWMutex + hashes map[string]sendRecorderValue +} + +func newSendRecorder() *sendRecorder { + return &sendRecorder{ + lock: &sync.RWMutex{}, + hashes: map[string]sendRecorderValue{}, + } +} + +func (q *sendRecorder) getMessageHash(message *pmapi.Message) string { + h := sha256.New() + _, _ = h.Write([]byte(message.AddressID + message.Subject)) + if message.Sender != nil { + _, _ = h.Write([]byte(message.Sender.Address)) + } + for _, to := range message.ToList { + _, _ = h.Write([]byte(to.Address)) + } + for _, to := range message.CCList { + _, _ = h.Write([]byte(to.Address)) + } + for _, to := range message.BCCList { + _, _ = h.Write([]byte(to.Address)) + } + _, _ = h.Write([]byte(message.Body)) + for _, att := range message.Attachments { + _, _ = h.Write([]byte(att.Name + att.MIMEType + fmt.Sprintf("%d", att.Size))) + } + return fmt.Sprintf("%x", h.Sum(nil)) +} + +func (q *sendRecorder) addMessage(hash, messageID string) { + q.lock.Lock() + defer q.lock.Unlock() + + q.deleteExpiredKeys() + q.hashes[hash] = sendRecorderValue{ + messageID: messageID, + time: time.Now(), + } +} + +func (q *sendRecorder) isSendingOrSent(client messageGetter, hash string) (isSending bool, wasSent bool) { + q.lock.Lock() + defer q.lock.Unlock() + + q.deleteExpiredKeys() + value, ok := q.hashes[hash] + if !ok { + return + } + message, err := client.GetMessage(value.messageID) + // Message could be deleted or there could be an internet issue or whatever, + // so let's assume the message was not sent. + if err != nil { + return + } + if message.Type == pmapi.MessageTypeDraft { + // If message is in draft for a long time, let's assume there is + // some problem and message will not be sent anymore. + if time.Since(time.Unix(message.Time, 0)).Minutes() > 10 { + return + } + isSending = true + } + // MessageTypeInboxAndSent can be when message was sent to myself. + if message.Type == pmapi.MessageTypeSent || message.Type == pmapi.MessageTypeInboxAndSent { + wasSent = true + } + return +} + +func (q *sendRecorder) deleteExpiredKeys() { + for key, value := range q.hashes { + // It's hard to find a good expiration time. + // On the one hand, a user could set up some cron job sending the same message over and over again (heartbeat). + // On the the other, a user could put the device into sleep mode while sending. + // Changing the expiration time will always make one of the edge cases worse. + // But both edge cases are something we don't care much about. Important thing is we don't send the same message many times. + if time.Since(value.time) > 30*time.Minute { + delete(q.hashes, key) + } + } +} diff --git a/internal/smtp/send_recorder_test.go b/internal/smtp/send_recorder_test.go new file mode 100644 index 00000000..84cc12be --- /dev/null +++ b/internal/smtp/send_recorder_test.go @@ -0,0 +1,414 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package smtp + +import ( + "errors" + "fmt" + "net/mail" + "testing" + "time" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/stretchr/testify/assert" +) + +type testSendRecorderGetMessageMock struct { + message *pmapi.Message + err error +} + +func (m *testSendRecorderGetMessageMock) GetMessage(messageID string) (*pmapi.Message, error) { + return m.message, m.err +} + +func TestSendRecorder_getMessageHash(t *testing.T) { + q := newSendRecorder() + + message := &pmapi.Message{ + AddressID: "address123", + Subject: "Subject #1", + Sender: &mail.Address{ + Address: "from@pm.me", + }, + ToList: []*mail.Address{ + {Address: "to@pm.me"}, + }, + CCList: []*mail.Address{}, + BCCList: []*mail.Address{}, + Body: "body", + Attachments: []*pmapi.Attachment{ + { + Name: "att1", + MIMEType: "image/png", + Size: 12345, + }, + }, + } + hash := q.getMessageHash(message) + + testCases := []struct { + message *pmapi.Message + expectEqual bool + }{ + { + message, + true, + }, + { + &pmapi.Message{}, + false, + }, + { // Different AddressID + &pmapi.Message{ + AddressID: "...", + Subject: "Subject #1", + Sender: &mail.Address{ + Address: "from@pm.me", + }, + ToList: []*mail.Address{ + {Address: "to@pm.me"}, + }, + CCList: []*mail.Address{}, + BCCList: []*mail.Address{}, + Body: "body", + Attachments: []*pmapi.Attachment{ + { + Name: "att1", + MIMEType: "image/png", + Size: 12345, + }, + }, + }, + false, + }, + { // Different subject + &pmapi.Message{ + AddressID: "address123", + Subject: "Subject #1.", + Sender: &mail.Address{ + Address: "from@pm.me", + }, + ToList: []*mail.Address{ + {Address: "to@pm.me"}, + }, + CCList: []*mail.Address{}, + BCCList: []*mail.Address{}, + Body: "body", + Attachments: []*pmapi.Attachment{ + { + Name: "att1", + MIMEType: "image/png", + Size: 12345, + }, + }, + }, + false, + }, + { // Different sender + &pmapi.Message{ + AddressID: "address123", + Subject: "Subject #1", + Sender: &mail.Address{ + Address: "sender@pm.me", + }, + ToList: []*mail.Address{ + {Address: "to@pm.me"}, + }, + CCList: []*mail.Address{}, + BCCList: []*mail.Address{}, + Body: "body", + Attachments: []*pmapi.Attachment{ + { + Name: "att1", + MIMEType: "image/png", + Size: 12345, + }, + }, + }, + false, + }, + { // Different ToList - changed address + &pmapi.Message{ + AddressID: "address123", + Subject: "Subject #1", + Sender: &mail.Address{ + Address: "from@pm.me", + }, + ToList: []*mail.Address{ + {Address: "other@pm.me"}, + }, + CCList: []*mail.Address{}, + BCCList: []*mail.Address{}, + Body: "body", + Attachments: []*pmapi.Attachment{ + { + Name: "att1", + MIMEType: "image/png", + Size: 12345, + }, + }, + }, + false, + }, + { // Different ToList - more addresses + &pmapi.Message{ + AddressID: "address123", + Subject: "Subject #1", + Sender: &mail.Address{ + Address: "from@pm.me", + }, + ToList: []*mail.Address{ + {Address: "to@pm.me"}, + {Address: "another@pm.me"}, + }, + CCList: []*mail.Address{}, + BCCList: []*mail.Address{}, + Body: "body", + Attachments: []*pmapi.Attachment{ + { + Name: "att1", + MIMEType: "image/png", + Size: 12345, + }, + }, + }, + false, + }, + { // Different CCList + &pmapi.Message{ + AddressID: "address123", + Subject: "Subject #1", + Sender: &mail.Address{ + Address: "from@pm.me", + }, + ToList: []*mail.Address{ + {Address: "to@pm.me"}, + }, + CCList: []*mail.Address{ + {Address: "to@pm.me"}, + }, + BCCList: []*mail.Address{}, + Body: "body", + Attachments: []*pmapi.Attachment{ + { + Name: "att1", + MIMEType: "image/png", + Size: 12345, + }, + }, + }, + false, + }, + { // Different BCCList + &pmapi.Message{ + AddressID: "address123", + Subject: "Subject #1", + Sender: &mail.Address{ + Address: "from@pm.me", + }, + ToList: []*mail.Address{ + {Address: "to@pm.me"}, + }, + CCList: []*mail.Address{}, + BCCList: []*mail.Address{ + {Address: "to@pm.me"}, + }, + Body: "body", + Attachments: []*pmapi.Attachment{ + { + Name: "att1", + MIMEType: "image/png", + Size: 12345, + }, + }, + }, + false, + }, + { // Different body + &pmapi.Message{ + AddressID: "address123", + Subject: "Subject #1", + Sender: &mail.Address{ + Address: "from@pm.me", + }, + ToList: []*mail.Address{ + {Address: "to@pm.me"}, + }, + CCList: []*mail.Address{}, + BCCList: []*mail.Address{}, + Body: "body.", + Attachments: []*pmapi.Attachment{ + { + Name: "att1", + MIMEType: "image/png", + Size: 12345, + }, + }, + }, + false, + }, + { // Different attachment - no attachment + &pmapi.Message{ + AddressID: "address123", + Subject: "Subject #1", + Sender: &mail.Address{ + Address: "from@pm.me", + }, + ToList: []*mail.Address{ + {Address: "to@pm.me"}, + }, + CCList: []*mail.Address{}, + BCCList: []*mail.Address{}, + Body: "body", + Attachments: []*pmapi.Attachment{}, + }, + false, + }, + { // Different attachment - name + &pmapi.Message{ + AddressID: "address123", + Subject: "Subject #1", + Sender: &mail.Address{ + Address: "from@pm.me", + }, + ToList: []*mail.Address{ + {Address: "to@pm.me"}, + }, + CCList: []*mail.Address{}, + BCCList: []*mail.Address{}, + Body: "body", + Attachments: []*pmapi.Attachment{ + { + Name: "...", + MIMEType: "image/png", + Size: 12345, + }, + }, + }, + false, + }, + { // Different attachment - MIMEType + &pmapi.Message{ + AddressID: "address123", + Subject: "Subject #1", + Sender: &mail.Address{ + Address: "from@pm.me", + }, + ToList: []*mail.Address{ + {Address: "to@pm.me"}, + }, + CCList: []*mail.Address{}, + BCCList: []*mail.Address{}, + Body: "body", + Attachments: []*pmapi.Attachment{ + { + Name: "att1", + MIMEType: "image/jpeg", + Size: 12345, + }, + }, + }, + false, + }, + { // Different attachment - Size + &pmapi.Message{ + AddressID: "address123", + Subject: "Subject #1", + Sender: &mail.Address{ + Address: "from@pm.me", + }, + ToList: []*mail.Address{ + {Address: "to@pm.me"}, + }, + CCList: []*mail.Address{}, + BCCList: []*mail.Address{}, + Body: "body", + Attachments: []*pmapi.Attachment{ + { + Name: "att1", + MIMEType: "image/png", + Size: 42, + }, + }, + }, + false, + }, + } + for i, tc := range testCases { + tc := tc // bind + t.Run(fmt.Sprintf("%d / %v", i, tc.message), func(t *testing.T) { + newHash := q.getMessageHash(tc.message) + if tc.expectEqual { + assert.Equal(t, hash, newHash) + } else { + assert.NotEqual(t, hash, newHash) + } + }) + } +} + +func TestSendRecorder_isSendingOrSent(t *testing.T) { + q := newSendRecorder() + q.addMessage("hash", "messageID") + + testCases := []struct { + hash string + message *pmapi.Message + err error + wantIsSending bool + wantWasSent bool + }{ + {"badhash", &pmapi.Message{Type: pmapi.MessageTypeDraft}, nil, false, false}, + {"hash", nil, errors.New("message not found"), false, false}, + {"hash", &pmapi.Message{Type: pmapi.MessageTypeInbox}, nil, false, false}, + {"hash", &pmapi.Message{Type: pmapi.MessageTypeDraft, Time: time.Now().Add(-20 * time.Minute).Unix()}, nil, false, false}, + {"hash", &pmapi.Message{Type: pmapi.MessageTypeDraft, Time: time.Now().Unix()}, nil, true, false}, + {"hash", &pmapi.Message{Type: pmapi.MessageTypeSent}, nil, false, true}, + {"hash", &pmapi.Message{Type: pmapi.MessageTypeInboxAndSent}, nil, false, true}, + } + for i, tc := range testCases { + tc := tc // bind + t.Run(fmt.Sprintf("%d / %v / %v / %v", i, tc.hash, tc.message, tc.err), func(t *testing.T) { + messageGetter := &testSendRecorderGetMessageMock{message: tc.message, err: tc.err} + isSending, wasSent := q.isSendingOrSent(messageGetter, "hash") + assert.Equal(t, tc.wantIsSending, isSending, "isSending does not match") + assert.Equal(t, tc.wantWasSent, wasSent, "wasSent does not match") + }) + } +} + +func TestSendRecorder_deleteExpiredKeys(t *testing.T) { + q := newSendRecorder() + + q.hashes["hash1"] = sendRecorderValue{ + messageID: "msg1", + time: time.Now(), + } + q.hashes["hash2"] = sendRecorderValue{ + messageID: "msg2", + time: time.Now().Add(-31 * time.Minute), + } + + q.deleteExpiredKeys() + + _, ok := q.hashes["hash1"] + assert.True(t, ok) + _, ok = q.hashes["hash2"] + assert.False(t, ok) +} diff --git a/internal/smtp/sending_info.go b/internal/smtp/sending_info.go new file mode 100644 index 00000000..a2912609 --- /dev/null +++ b/internal/smtp/sending_info.go @@ -0,0 +1,244 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package smtp + +import ( + "errors" + + pmcrypto "github.com/ProtonMail/gopenpgp/crypto" + "github.com/ProtonMail/proton-bridge/internal/events" + "github.com/ProtonMail/proton-bridge/pkg/algo" + "github.com/ProtonMail/proton-bridge/pkg/listener" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" +) + +const ( + pgpInline = "pgp-inline" + pgpMime = "pgp-mime" +) + +type SendingInfo struct { + Encrypt bool + Sign bool + Scheme int + MIMEType string + PublicKey *pmcrypto.KeyRing +} + +func generateSendingInfo( + eventListener listener.Listener, + contactMeta *ContactMetadata, + isInternal bool, + composeMode string, + apiKeys, + contactKeys []*pmcrypto.KeyRing, + settingsSign bool, + settingsPgpScheme int) (sendingInfo SendingInfo, err error) { + contactKeys, err = pmcrypto.FilterExpiredKeys(contactKeys) + if err != nil { + return + } + + if isInternal { + sendingInfo, err = generateInternalSendingInfo(eventListener, contactMeta, composeMode, apiKeys, contactKeys, settingsSign, settingsPgpScheme) + } else { + sendingInfo, err = generateExternalSendingInfo(contactMeta, composeMode, apiKeys, contactKeys, settingsSign, settingsPgpScheme) + } + + if (sendingInfo.Scheme == pmapi.PGPInlinePackage || sendingInfo.Scheme == pmapi.PGPMIMEPackage) && sendingInfo.PublicKey == nil { + return sendingInfo, errors.New("public key nil during attempt to encrypt") + } + + return +} + +func generateInternalSendingInfo( + eventListener listener.Listener, + contactMeta *ContactMetadata, + composeMode string, + apiKeys, + contactKeys []*pmcrypto.KeyRing, + settingsSign bool, //nolint[unparam] + settingsPgpScheme int) (sendingInfo SendingInfo, err error) { //nolint[unparam] + // If sending internally, there should always be a public key; if not, there's an error. + if len(apiKeys) == 0 { + err = errors.New("no valid public keys found for contact") + return + } + + // The default settings, unless overridden by presence of a saved contact. + sendingInfo = SendingInfo{ + Encrypt: true, + Sign: true, + Scheme: pmapi.InternalPackage, + MIMEType: composeMode, + PublicKey: apiKeys[0], + } + + // If there is no saved contact, our work here is done. + if contactMeta == nil { + return + } + + // If contact has a pinned key, prefer that over the api key (if it's not expired). + checkedContactKeys, err := checkContactKeysAgainstAPI(contactKeys, apiKeys) + if err != nil { + return + } + + // If we find no matching keys with the api but the contact still has pinned keys + // it means the pinned keys are out of date (e.g. the contact has since changed their protonmail + // keys and so the keys returned via the api don't match the keys pinned in the contact). + if len(checkedContactKeys) == 0 && len(contactKeys) != 0 { + eventListener.Emit(events.NoActiveKeyForRecipientEvent, contactMeta.Email) + return sendingInfo, errors.New("found no active key for recipient " + contactMeta.Email + ", please check contact settings") + } + + if len(checkedContactKeys) > 0 { + sendingInfo.PublicKey = checkedContactKeys[0] + } + + // If contact has a saved mime type preference, prefer that over the default. + if len(contactMeta.MIMEType) > 0 { + sendingInfo.MIMEType = contactMeta.MIMEType + } + + return sendingInfo, nil +} + +func generateExternalSendingInfo( + contactMeta *ContactMetadata, + composeMode string, + apiKeys, + contactKeys []*pmcrypto.KeyRing, + settingsSign bool, + settingsPgpScheme int) (sendingInfo SendingInfo, err error) { + // The default settings, unless overridden by presence of a saved contact. + sendingInfo = SendingInfo{ + Encrypt: false, + Sign: settingsSign, + PublicKey: nil, + } + + if contactMeta != nil && len(contactKeys) > 0 { + // If the contact has a key, use it. And if the contact metadata says to encryt, do so. + sendingInfo.PublicKey = contactKeys[0] + sendingInfo.Encrypt = contactMeta.Encrypt + } else if len(apiKeys) > 0 { + // If the api returned a key (via WKD), use it. In this case we always encrypt. + sendingInfo.PublicKey = apiKeys[0] + sendingInfo.Encrypt = true + } + + // - If we are encrypting, we always sign + // - else if the contact has a preference, we follow that + // - otherwise, we fall back to the mailbox default signing settings + if sendingInfo.Encrypt { //nolint[gocritic] + sendingInfo.Sign = true + } else if contactMeta != nil && !contactMeta.SignMissing { + sendingInfo.Sign = contactMeta.Sign + } else { + sendingInfo.Sign = settingsSign + } + + sendingInfo.Scheme, sendingInfo.MIMEType, err = schemeAndMIME(contactMeta, + settingsPgpScheme, + composeMode, + sendingInfo.Encrypt, + sendingInfo.Sign) + + return sendingInfo, err +} + +func schemeAndMIME(contact *ContactMetadata, settingsScheme int, settingsMIMEType string, encrypted, signed bool) (scheme int, mime string, err error) { + if encrypted && signed { + // Prefer contact settings. + if contact != nil && contact.Scheme == pgpInline { + return pmapi.PGPInlinePackage, pmapi.ContentTypePlainText, nil + } else if contact != nil && contact.Scheme == pgpMime { + return pmapi.PGPMIMEPackage, pmapi.ContentTypeMultipartMixed, nil + } + + // If no contact settings, follow mailbox defaults. + scheme = settingsScheme + if scheme == pmapi.PGPMIMEPackage { + return scheme, pmapi.ContentTypeMultipartMixed, nil + } else if scheme == pmapi.PGPInlinePackage { + return scheme, pmapi.ContentTypePlainText, nil + } + } + + if !encrypted && signed { + // Prefer contact settings but send unencrypted (PGP-->Clear). + if contact != nil && contact.Scheme == pgpMime { + return pmapi.ClearMIMEPackage, pmapi.ContentTypeMultipartMixed, nil + } else if contact != nil && contact.Scheme == pgpInline { + return pmapi.ClearPackage, pmapi.ContentTypePlainText, nil + } + + // If no contact settings, follow mailbox defaults but send unencrypted (PGP-->Clear). + if settingsScheme == pmapi.PGPMIMEPackage { + return pmapi.ClearMIMEPackage, pmapi.ContentTypeMultipartMixed, nil + } else if settingsScheme == pmapi.PGPInlinePackage { + return pmapi.ClearPackage, pmapi.ContentTypePlainText, nil + } + } + + if !encrypted && !signed { + // Always send as clear package if we are neither encrypting nor signing. + scheme = pmapi.ClearPackage + + // If the contact is nil, no further modifications can be made. + if contact == nil { + return scheme, settingsMIMEType, nil + } + + // Prefer contact mime settings. + if contact.Scheme == pgpMime { + return scheme, pmapi.ContentTypeMultipartMixed, nil + } else if contact.Scheme == pgpInline { + return scheme, pmapi.ContentTypePlainText, nil + } + + // If contact has a preferred mime type, use that, otherwise follow mailbox default. + if len(contact.MIMEType) > 0 { + return scheme, contact.MIMEType, nil + } + return scheme, settingsMIMEType, nil + } + + // If we end up here, something went wrong. + err = errors.New("could not determine correct PGP Scheme and MIME Type to use to send mail") + + return scheme, mime, err +} + +// checkContactKeysAgainstAPI keeps only those contact keys which are up to date and have +// an ID that matches an API key's ID. +func checkContactKeysAgainstAPI(contactKeys, apiKeys []*pmcrypto.KeyRing) (filteredKeys []*pmcrypto.KeyRing, err error) { //nolint[unparam] + keyIDsAreEqual := func(a, b interface{}) bool { + aKey, bKey := a.(*pmcrypto.KeyRing), b.(*pmcrypto.KeyRing) + return aKey.GetEntities()[0].PrimaryKey.KeyId == bKey.GetEntities()[0].PrimaryKey.KeyId + } + + for _, v := range algo.SetIntersection(contactKeys, apiKeys, keyIDsAreEqual) { + filteredKeys = append(filteredKeys, v.(*pmcrypto.KeyRing)) + } + + return +} diff --git a/internal/smtp/sending_info_test.go b/internal/smtp/sending_info_test.go new file mode 100644 index 00000000..64d81a99 --- /dev/null +++ b/internal/smtp/sending_info_test.go @@ -0,0 +1,604 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package smtp + +import ( + "strings" + "testing" + + pmcrypto "github.com/ProtonMail/gopenpgp/crypto" + "github.com/ProtonMail/proton-bridge/internal/bridge" + "github.com/ProtonMail/proton-bridge/internal/events" + "github.com/ProtonMail/proton-bridge/pkg/listener" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" +) + +type mocks struct { + t *testing.T + eventListener *bridge.MockListener +} + +func initMocks(t *testing.T) mocks { + mockCtrl := gomock.NewController(t) + return mocks{ + t: t, + eventListener: bridge.NewMockListener(mockCtrl), + } +} + +type args struct { + eventListener listener.Listener + contactMeta *ContactMetadata + apiKeys []*pmcrypto.KeyRing + contactKeys []*pmcrypto.KeyRing + composeMode string + settingsPgpScheme int + settingsSign bool + isInternal bool +} + +type testData struct { + name string + args args + wantSendingInfo SendingInfo + wantErr bool +} + +func (tt *testData) runTest(t *testing.T) { + t.Run(tt.name, func(t *testing.T) { + gotSendingInfo, err := generateSendingInfo(tt.args.eventListener, tt.args.contactMeta, tt.args.isInternal, tt.args.composeMode, tt.args.apiKeys, tt.args.contactKeys, tt.args.settingsSign, tt.args.settingsPgpScheme) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, gotSendingInfo, tt.wantSendingInfo) + } + }) +} + +func TestGenerateSendingInfo_WithoutContact(t *testing.T) { + m := initMocks(t) + + pubKey, err := pmcrypto.ReadArmoredKeyRing(strings.NewReader(testPublicKey)) + if err != nil { + panic(err) + } + + tests := []testData{ + { + name: "internal, PGP_MIME", + args: args{ + contactMeta: nil, + isInternal: true, + composeMode: pmapi.ContentTypeHTML, + apiKeys: []*pmcrypto.KeyRing{pubKey}, + contactKeys: []*pmcrypto.KeyRing{}, + settingsSign: true, + settingsPgpScheme: pmapi.PGPMIMEPackage, + }, + wantSendingInfo: SendingInfo{ + Encrypt: true, + Sign: true, + Scheme: pmapi.InternalPackage, + MIMEType: pmapi.ContentTypeHTML, + PublicKey: pubKey, + }, + }, + { + name: "internal, PGP_INLINE", + args: args{ + contactMeta: nil, + isInternal: true, + composeMode: pmapi.ContentTypeHTML, + apiKeys: []*pmcrypto.KeyRing{pubKey}, + contactKeys: []*pmcrypto.KeyRing{}, + settingsSign: true, + settingsPgpScheme: pmapi.PGPInlinePackage, + }, + wantSendingInfo: SendingInfo{ + Encrypt: true, + Sign: true, + Scheme: pmapi.InternalPackage, + MIMEType: pmapi.ContentTypeHTML, + PublicKey: pubKey, + }, + }, + { + name: "external, PGP_MIME", + args: args{ + contactMeta: nil, + isInternal: false, + composeMode: pmapi.ContentTypeHTML, + apiKeys: []*pmcrypto.KeyRing{}, + contactKeys: []*pmcrypto.KeyRing{}, + settingsSign: true, + settingsPgpScheme: pmapi.PGPMIMEPackage, + }, + wantSendingInfo: SendingInfo{ + Encrypt: false, + Sign: true, + Scheme: pmapi.ClearMIMEPackage, + MIMEType: pmapi.ContentTypeMultipartMixed, + PublicKey: nil, + }, + }, + { + name: "external, PGP_INLINE", + args: args{ + contactMeta: nil, + isInternal: false, + composeMode: pmapi.ContentTypeHTML, + apiKeys: []*pmcrypto.KeyRing{}, + contactKeys: []*pmcrypto.KeyRing{}, + settingsSign: true, + settingsPgpScheme: pmapi.PGPInlinePackage, + }, + wantSendingInfo: SendingInfo{ + Encrypt: false, + Sign: true, + Scheme: pmapi.ClearPackage, + MIMEType: pmapi.ContentTypePlainText, + PublicKey: nil, + }, + }, + { + name: "external, PGP_MIME, Unsigned", + args: args{ + contactMeta: nil, + isInternal: false, + composeMode: pmapi.ContentTypeHTML, + apiKeys: []*pmcrypto.KeyRing{}, + contactKeys: []*pmcrypto.KeyRing{}, + settingsSign: false, + settingsPgpScheme: pmapi.PGPInlinePackage, + }, + wantSendingInfo: SendingInfo{ + Encrypt: false, + Sign: false, + Scheme: pmapi.ClearPackage, + MIMEType: pmapi.ContentTypeHTML, + PublicKey: nil, + }, + }, + { + name: "internal, error no valid public key", + args: args{ + eventListener: m.eventListener, + contactMeta: nil, + isInternal: true, + apiKeys: []*pmcrypto.KeyRing{}, + contactKeys: []*pmcrypto.KeyRing{pubKey}, + }, + wantSendingInfo: SendingInfo{}, + wantErr: true, + }, + { + name: "external, no pinned key but receive one via WKD", + args: args{ + contactMeta: nil, + isInternal: false, + composeMode: pmapi.ContentTypeHTML, + apiKeys: []*pmcrypto.KeyRing{pubKey}, + contactKeys: []*pmcrypto.KeyRing{}, + settingsSign: true, + settingsPgpScheme: pmapi.PGPMIMEPackage, + }, + wantSendingInfo: SendingInfo{ + Encrypt: true, + Sign: true, + Scheme: pmapi.PGPMIMEPackage, + MIMEType: pmapi.ContentTypeMultipartMixed, + PublicKey: pubKey, + }, + }, + } + for _, tt := range tests { + tt.runTest(t) + } +} + +func TestGenerateSendingInfo_Contact_Internal(t *testing.T) { + m := initMocks(t) + + pubKey, err := pmcrypto.ReadArmoredKeyRing(strings.NewReader(testPublicKey)) + if err != nil { + panic(err) + } + + preferredPubKey, err := pmcrypto.ReadArmoredKeyRing(strings.NewReader(testPublicKey)) + if err != nil { + panic(err) + } + + differentPubKey, err := pmcrypto.ReadArmoredKeyRing(strings.NewReader(testDifferentPublicKey)) + if err != nil { + panic(err) + } + + m.eventListener.EXPECT().Emit(events.NoActiveKeyForRecipientEvent, "badkey@email.com") + + tests := []testData{ + { + name: "PGP_MIME, contact wants pgp-mime, no pinned key", + args: args{ + contactMeta: &ContactMetadata{Encrypt: true, Scheme: "pgp-mime"}, + isInternal: true, + composeMode: pmapi.ContentTypeHTML, + apiKeys: []*pmcrypto.KeyRing{pubKey}, + contactKeys: []*pmcrypto.KeyRing{}, + settingsSign: true, + settingsPgpScheme: pmapi.PGPMIMEPackage, + }, + wantSendingInfo: SendingInfo{ + Encrypt: true, + Sign: true, + Scheme: pmapi.InternalPackage, + MIMEType: pmapi.ContentTypeHTML, + PublicKey: pubKey, + }, + }, + { + name: "PGP_MIME, contact wants pgp-mime, pinned key", + args: args{ + contactMeta: &ContactMetadata{Encrypt: true, Scheme: "pgp-mime"}, + isInternal: true, + composeMode: pmapi.ContentTypeHTML, + apiKeys: []*pmcrypto.KeyRing{pubKey}, + contactKeys: []*pmcrypto.KeyRing{pubKey}, + settingsSign: true, + settingsPgpScheme: pmapi.PGPMIMEPackage, + }, + wantSendingInfo: SendingInfo{ + Encrypt: true, + Sign: true, + Scheme: pmapi.InternalPackage, + MIMEType: pmapi.ContentTypeHTML, + PublicKey: pubKey, + }, + }, + { + name: "PGP_MIME, contact wants pgp-mime, pinned key but prefer api key", + args: args{ + contactMeta: &ContactMetadata{Encrypt: true, Scheme: "pgp-mime"}, + isInternal: true, + composeMode: pmapi.ContentTypeHTML, + apiKeys: []*pmcrypto.KeyRing{preferredPubKey}, + contactKeys: []*pmcrypto.KeyRing{pubKey}, + settingsSign: true, + settingsPgpScheme: pmapi.PGPMIMEPackage, + }, + wantSendingInfo: SendingInfo{ + Encrypt: true, + Sign: true, + Scheme: pmapi.InternalPackage, + MIMEType: pmapi.ContentTypeHTML, + PublicKey: preferredPubKey, + }, + }, + { + name: "internal, found no active key for recipient", + args: args{ + eventListener: m.eventListener, + contactMeta: &ContactMetadata{Email: "badkey@email.com", Encrypt: true, Scheme: "pgp-mime"}, + isInternal: true, + composeMode: pmapi.ContentTypeHTML, + apiKeys: []*pmcrypto.KeyRing{pubKey}, + contactKeys: []*pmcrypto.KeyRing{differentPubKey}, + settingsSign: true, + settingsPgpScheme: pmapi.PGPMIMEPackage, + }, + wantSendingInfo: SendingInfo{}, + wantErr: true, + }, + { + name: "external, contact saved, no pinned key but receive one via WKD", + args: args{ + contactMeta: &ContactMetadata{Encrypt: true, Scheme: "pgp-mime"}, + isInternal: false, + composeMode: pmapi.ContentTypeHTML, + apiKeys: []*pmcrypto.KeyRing{pubKey}, + contactKeys: []*pmcrypto.KeyRing{}, + settingsSign: true, + settingsPgpScheme: pmapi.PGPMIMEPackage, + }, + wantSendingInfo: SendingInfo{ + Encrypt: true, + Sign: true, + Scheme: pmapi.PGPMIMEPackage, + MIMEType: pmapi.ContentTypeMultipartMixed, + PublicKey: pubKey, + }, + }, + { + name: "external, contact saved, pinned key but receive different one via WKD", + args: args{ + contactMeta: &ContactMetadata{Encrypt: true, Scheme: "pgp-mime"}, + isInternal: false, + composeMode: pmapi.ContentTypeHTML, + apiKeys: []*pmcrypto.KeyRing{pubKey}, + contactKeys: []*pmcrypto.KeyRing{differentPubKey}, + settingsSign: true, + settingsPgpScheme: pmapi.PGPMIMEPackage, + }, + wantSendingInfo: SendingInfo{ + Encrypt: true, + Sign: true, + Scheme: pmapi.PGPMIMEPackage, + MIMEType: pmapi.ContentTypeMultipartMixed, + PublicKey: differentPubKey, + }, + }, + } + for _, tt := range tests { + tt.runTest(t) + } +} + +func TestGenerateSendingInfo_Contact_External(t *testing.T) { + pubKey, err := pmcrypto.ReadArmoredKeyRing(strings.NewReader(testPublicKey)) + if err != nil { + panic(err) + } + + expiredPubKey, err := pmcrypto.ReadArmoredKeyRing(strings.NewReader(testExpiredPublicKey)) + if err != nil { + panic(err) + } + + tests := []testData{ + { + name: "PGP_MIME, no pinned key", + args: args{ + contactMeta: &ContactMetadata{}, + isInternal: false, + composeMode: pmapi.ContentTypeHTML, + apiKeys: []*pmcrypto.KeyRing{}, + contactKeys: []*pmcrypto.KeyRing{}, + settingsSign: true, + settingsPgpScheme: pmapi.PGPMIMEPackage, + }, + wantSendingInfo: SendingInfo{ + Encrypt: false, + Sign: false, + Scheme: pmapi.ClearPackage, + MIMEType: pmapi.ContentTypeHTML, + PublicKey: nil, + }, + }, + { + name: "PGP_MIME, pinned key but it's expired", + args: args{ + contactMeta: &ContactMetadata{}, + isInternal: false, + composeMode: pmapi.ContentTypeHTML, + apiKeys: []*pmcrypto.KeyRing{}, + contactKeys: []*pmcrypto.KeyRing{expiredPubKey}, + settingsSign: true, + settingsPgpScheme: pmapi.PGPMIMEPackage, + }, + wantSendingInfo: SendingInfo{ + Encrypt: false, + Sign: false, + Scheme: pmapi.ClearPackage, + MIMEType: pmapi.ContentTypeHTML, + PublicKey: nil, + }, + }, + { + name: "PGP_MIME, contact wants pgp-mime, pinned key", + args: args{ + contactMeta: &ContactMetadata{Encrypt: true, Scheme: "pgp-mime"}, + isInternal: false, + composeMode: pmapi.ContentTypeHTML, + apiKeys: []*pmcrypto.KeyRing{}, + contactKeys: []*pmcrypto.KeyRing{pubKey}, + settingsSign: true, + settingsPgpScheme: pmapi.PGPMIMEPackage, + }, + wantSendingInfo: SendingInfo{ + Encrypt: true, + Sign: true, + Scheme: pmapi.PGPMIMEPackage, + MIMEType: pmapi.ContentTypeMultipartMixed, + PublicKey: pubKey, + }, + }, + { + name: "PGP_MIME, contact wants pgp-inline, pinned key", + args: args{ + contactMeta: &ContactMetadata{Encrypt: true, Scheme: "pgp-inline"}, + isInternal: false, + composeMode: pmapi.ContentTypeHTML, + apiKeys: []*pmcrypto.KeyRing{}, + contactKeys: []*pmcrypto.KeyRing{pubKey}, + settingsSign: true, + settingsPgpScheme: pmapi.PGPMIMEPackage, + }, + wantSendingInfo: SendingInfo{ + Encrypt: true, + Sign: true, + Scheme: pmapi.PGPInlinePackage, + MIMEType: pmapi.ContentTypePlainText, + PublicKey: pubKey, + }, + }, + { + name: "PGP_MIME, contact wants default scheme, pinned key", + args: args{ + contactMeta: &ContactMetadata{Encrypt: true}, + isInternal: false, + composeMode: pmapi.ContentTypeHTML, + apiKeys: []*pmcrypto.KeyRing{}, + contactKeys: []*pmcrypto.KeyRing{pubKey}, + settingsSign: true, + settingsPgpScheme: pmapi.PGPMIMEPackage, + }, + wantSendingInfo: SendingInfo{ + Encrypt: true, + Sign: true, + Scheme: pmapi.PGPMIMEPackage, + MIMEType: pmapi.ContentTypeMultipartMixed, + PublicKey: pubKey, + }, + }, + { + name: "PGP_INLINE, contact wants default scheme, no pinned key", + args: args{ + contactMeta: &ContactMetadata{}, + isInternal: false, + composeMode: pmapi.ContentTypeHTML, + apiKeys: []*pmcrypto.KeyRing{}, + contactKeys: []*pmcrypto.KeyRing{}, + settingsSign: true, + settingsPgpScheme: pmapi.PGPInlinePackage, + }, + wantSendingInfo: SendingInfo{ + Encrypt: false, + Sign: false, + Scheme: pmapi.ClearPackage, + MIMEType: pmapi.ContentTypeHTML, + PublicKey: nil, + }, + }, + { + name: "PGP_INLINE, contact wants plain text, no pinned key", + args: args{ + contactMeta: &ContactMetadata{MIMEType: pmapi.ContentTypePlainText}, + isInternal: false, + composeMode: pmapi.ContentTypeHTML, + apiKeys: []*pmcrypto.KeyRing{}, + contactKeys: []*pmcrypto.KeyRing{}, + settingsSign: true, + settingsPgpScheme: pmapi.PGPInlinePackage, + }, + wantSendingInfo: SendingInfo{ + Encrypt: false, + Sign: false, + Scheme: pmapi.ClearPackage, + MIMEType: pmapi.ContentTypePlainText, + PublicKey: nil, + }, + }, + { + name: "PGP_INLINE, contact sign missing, no pinned key", + args: args{ + contactMeta: &ContactMetadata{SignMissing: true}, + isInternal: false, + composeMode: pmapi.ContentTypeHTML, + apiKeys: []*pmcrypto.KeyRing{}, + contactKeys: []*pmcrypto.KeyRing{}, + settingsSign: true, + settingsPgpScheme: pmapi.PGPInlinePackage, + }, + wantSendingInfo: SendingInfo{ + Encrypt: false, + Sign: true, + Scheme: pmapi.ClearPackage, + MIMEType: pmapi.ContentTypePlainText, + PublicKey: nil, + }, + }, + } + for _, tt := range tests { + tt.runTest(t) + } +} + +const testPublicKey = ` +-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: OpenPGP.js v0.7.1 +Comment: http://openpgpjs.org + +xsBNBFRJbc0BCAC0mMLZPDBbtSCWvxwmOfXfJkE2+ssM3ux21LhD/bPiWefE +WSHlCjJ8PqPHy7snSiUuxuj3f9AvXPvg+mjGLBwu1/QsnSP24sl3qD2onl39 +vPiLJXUqZs20ZRgnvX70gjkgEzMFBxINiy2MTIG+4RU8QA7y8KzWev0btqKi +MeVa+GLEHhgZ2KPOn4Jv1q4bI9hV0C9NUe2tTXS6/Vv3vbCY7lRR0kbJ65T5 +c8CmpqJuASIJNrSXM/Q3NnnsY4kBYH0s5d2FgbASQvzrjuC2rngUg0EoPsrb +DEVRA2/BCJonw7aASiNCrSP92lkZdtYlax/pcoE/mQ4WSwySFmcFT7yFABEB +AAHNBlVzZXJJRMLAcgQQAQgAJgUCVEltzwYLCQgHAwIJED62JZ7fId8kBBUI +AgoDFgIBAhsDAh4BAAD0nQf9EtH9TC0JqSs8q194Zo244jjlJFM3EzxOSULq +0zbywlLORfyoo/O8jU/HIuGz+LT98JDtnltTqfjWgu6pS3ZL2/L4AGUKEoB7 +OI6oIdRwzMc61sqI+Qpbzxo7rzufH4CiXZc6cxORUgL550xSCcqnq0q1mds7 +h5roKDzxMW6WLiEsc1dN8IQKzC7Ec5wA7U4oNGsJ3TyI8jkIs0IhXrRCd26K +0TW8Xp6GCsfblWXosR13y89WVNgC+xrrJKTZEisc0tRlneIgjcwEUvwfIg2n +9cDUFA/5BsfzTW5IurxqDEziIVP0L44PXjtJrBQaGMPlEbtP5i2oi3OADVX2 +XbvsRc7ATQRUSW3PAQgAkPnu5fps5zhOB/e618v/iF3KiogxUeRhA68TbvA+ +xnFfTxCx2Vo14aOL0CnaJ8gO5yRSqfomL2O1kMq07N1MGbqucbmc+aSfoElc ++Gd5xBE/w3RcEhKcAaYTi35vG22zlZup4x3ElioyIarOssFEkQgNNyDf5AXZ +jdHLA6qVxeqAb/Ff74+y9HUmLPSsRU9NwFzvK3Jv8C/ubHVLzTYdFgYkc4W1 +Uug9Ou08K+/4NEMrwnPFBbZdJAuUjQz2zW2ZiEKiBggiorH2o5N3mYUnWEmU +vqL3EOS8TbWo8UBIW3DDm2JiZR8VrEgvBtc9mVDUj/x+5pR07Fy1D6DjRmAc +9wARAQABwsBfBBgBCAATBQJUSW3SCRA+tiWe3yHfJAIbDAAA/iwH/ik9RKZM +B9Ir0x5mGpKPuqhugwrc3d04m1sOdXJm2NtD4ddzSEvzHwaPNvEvUl5v7FVM +zf6+6mYGWHyNP4+e7RtwYLlRpud6smuGyDSsotUYyumiqP6680ZIeWVQ+a1T +ThNs878mAJy1FhvQFdTmA8XIC616hDFpamQKPlpoO1a0wZnQhrPwT77HDYEE +a+hqY4Jr/a7ui40S+7xYRHKL/7ZAS4/grWllhU3dbNrwSzrOKwrA/U0/9t73 +8Ap6JL71YymDeaL4sutcoaahda1pTrMWePtrCltz6uySwbZs7GXoEzjX3EAH ++6qhkUJtzMaE3YEFEoQMGzcDTUEfXCJ3zJw= +=yT9U +-----END PGP PUBLIC KEY BLOCK----- +` + +const testDifferentPublicKey = ` +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQENBF2Ix1EBCACwkUAn/5+sO1feDcS+aQ9BskESOyf1tBS8EDyz4deHFqnzoVCx +pJNvF7jb5J0AFCO/I5Mg7ddJb1udd/Eq+aZKfNYgjlvpdnW2Lo6Y0a5I5sm8R+vW +6EPQGxdgT7QG0VbeekGuy+F4o0KrgvJ4Sl3020q/Vix5B8ovtS6LGB22NWn5FGbL ++ssmq3tr3o2Q2jmHEIMTN4LOk1C4oHCljwrl7UP2MrER/if+czva3dB2jQgto6ia +o0+myIHkIjEKz5q7EGaGn9b7TEWk6+qNFRlKSa3GEFy4DXuQuysb+imjuP8uFxwb +/ib4QoOd/lAkrAVrcUHoWWhtBinsGEBXlG0LABEBAAG0GmphbWVzLXRlc3RAcHJv +dG9ubWFpbC5ibHVliQFUBBMBCAA+FiEEIwbxzW52iRgG0YMKojP3Zu/mCXIFAl2I +x1ECGwMFCQPCZwAFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AACgkQojP3Zu/mCXJu +iQf+PiGA0sLEHx0gn2TRoYe7NOn9cnbi+KMLPIFJLGG4mAdVnEVNgaEvMGsNnC14 +3FNIVaSdIR5/4ebtplZIlJWb8zxyaNTFkOJexnzwLw2p2cMF78Vsc4sAVLL5Y068 +0v6KUzSK2cI1D4kvCyVK57jZL5dURCyISQrekYN/qhQb/TXbbUuznIJURTnLIq6k +v3E6SPB0hKksPgYlQaRocICw7ybbFur7gavyYlyZwD22JSGjwkJBSBi9dj14OD5Q +Egrd7E0qMd6BPzdlV9bctRabyUQLVjWFq8Nw4cC8AW7j7ENq6QIsuM2iKPf9M/HR +5U+Q9hUxcaG/Sv72QI7M4Qc4DrkBDQRdiMdRAQgA7Qufpv+RrZzcxYyfRf4SWZu5 +Geo4Zke/AzlkTsw3MgMJHxiSXxEZdU4u/NRQeK53sEQ9J5iIuuzdjLbs5ECT4PjI +G8Lw6LtsCQ6WW9Gc7RUQNsXErIYidfk+v2zsJTHkP9aGkAgEe92bu87SSGXKO1In +w3e04wPjXeZ3ZYw2NovtPFNKVqBrglmN2WMTUXqOXNtcHCn/x5hQfuyo41wTol1m +YrZCiWu+Nxt6nEWQHA3hw0Dp8byCd/9yhIbn21cCZbX2aITYZL4pFbemMGfeteZF +eDVDxAXPFtat9pzgFe8wmF1kDrvnEsjvbb5UjmtlWZr0EWGoBkiioVh4/pyVMwAR +AQABiQE2BBgBCAAgFiEEIwbxzW52iRgG0YMKojP3Zu/mCXIFAl2Ix1ECGwwACgkQ +ojP3Zu/mCXLJZAf9Hbfu7FraFdl2DwYO815XFukMCAIUzhIMrLhUFO1WWg/m44bm +6OZ8NockPl8Mx3CjSG5Kjuk9h5AOG/doOVQL+i8ktQ7VsF4G9tBEgcxjacoGvNZH +VP1gFScmnI4rSfduhHf8JKToTJvK/KOFnko4/2fzM2WH3VLu7qZgT3RufuUn5LLn +C7eju/gf4WQZUtMTJODzs/EaHOkFevrJ7c6IIAUWD12sA6WHEC3l/mQuc9iXlyJw +HyMl6JQldr4XCcdTu73uSvVJ/1IkvLiHPuPP9ma9+FClaUGOmUws7rNQ3ODX52tx +bIYA5I4XbBMze46izlbEAKt6wHhQWTGlSpts0A== +=cOfs +-----END PGP PUBLIC KEY BLOCK-----` + +const testExpiredPublicKey = ` +-----BEGIN PGP PRIVATE KEY BLOCK----- + +xcA4BAAAAAEBAgCgONc0J8rfO6cJw5YTP38x1ze2tAYIO7EcmRCNYwMkXngb +0Qdzg34Q5RW0rNiR56VB6KElPUhePRPVklLFiIvHABEBAAEAAf9qabYMzsz/ +/LeRVZSsTgTljmJTdzd2ambUbpi+vt8MXJsbaWh71vjoLMWSXajaKSPDjVU5 +waFNt9kLqwGGGLqpAQD5ZdMH2XzTq6GU9Ka69iZs6Pbnzwdz59Vc3i8hXlUj +zQEApHargCTsrtvSrm+hK/pN51/BHAy9lxCAw9f2etx+AeMA/RGrijkFZtYt +jeWdv/usXL3mgHvEcJv63N5zcEvDX5X4W1bND3Rlc3QxIDxhQGIuY29tPsJ7 +BBABCAAvBQIAAAABBQMAAAU5BgsJBwgDAgkQzcF99nGrkAkEFQgKAgMWAgEC +GQECGwMCHgEAABAlAfwPehmLZs+gOhOTTaSslqQ50bl/REjmv42Nyr1ZBlQS +DECl1Qu4QyeXin29uEXWiekMpNlZVsEuc8icCw6ABhIZ +=/7PI +-----END PGP PRIVATE KEY BLOCK-----` diff --git a/internal/smtp/server.go b/internal/smtp/server.go new file mode 100644 index 00000000..ad06bf5d --- /dev/null +++ b/internal/smtp/server.go @@ -0,0 +1,112 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package smtp + +import ( + "crypto/tls" + "fmt" + + "github.com/ProtonMail/proton-bridge/internal/bridge" + "github.com/ProtonMail/proton-bridge/internal/events" + "github.com/ProtonMail/proton-bridge/pkg/listener" + "github.com/emersion/go-sasl" + goSMTP "github.com/emersion/go-smtp" + "github.com/sirupsen/logrus" +) + +type smtpServer struct { + server *goSMTP.Server + eventListener listener.Listener + useSSL bool +} + +// NewSMTPServer returns an SMTP server configured with the given options. +func NewSMTPServer(debug bool, port int, useSSL bool, tls *tls.Config, smtpBackend goSMTP.Backend, eventListener listener.Listener) *smtpServer { //nolint[golint] + s := goSMTP.NewServer(smtpBackend) + s.Addr = fmt.Sprintf("%v:%v", bridge.Host, port) + s.TLSConfig = tls + s.Domain = bridge.Host + s.AllowInsecureAuth = true + + if debug { + s.Debug = logrus. + WithField("pkg", "smtp/server"). + WriterLevel(logrus.DebugLevel) + } + + s.EnableAuth(sasl.Login, func(conn *goSMTP.Conn) sasl.Server { + return sasl.NewLoginServer(func(address, password string) error { + user, err := conn.Server().Backend.Login(address, password) + if err != nil { + return err + } + + conn.SetUser(user) + return nil + }) + }) + + return &smtpServer{ + server: s, + eventListener: eventListener, + useSSL: useSSL, + } +} + +// Starts the server. +func (s *smtpServer) ListenAndServe() { + go s.monitorDisconnectedUsers() + l := log.WithField("useSSL", s.useSSL).WithField("address", s.server.Addr) + + l.Info("SMTP server is starting") + var err error + if s.useSSL { + err = s.server.ListenAndServeTLS() + } else { + err = s.server.ListenAndServe() + } + if err != nil { + s.eventListener.Emit(events.ErrorEvent, "SMTP failed: "+err.Error()) + l.Error("SMTP failed: ", err) + return + } + defer s.server.Close() + + l.Info("SMTP server stopped") +} + +// Stops the server. +func (s *smtpServer) Close() { + s.server.Close() +} + +func (s *smtpServer) monitorDisconnectedUsers() { + ch := make(chan string) + s.eventListener.Add(events.CloseConnectionEvent, ch) + + for address := range ch { + log.Info("Disconnecting all open SMTP connections for ", address) + disconnectUser := func(conn *goSMTP.Conn) { + connUser := conn.User() + if connUser != nil { + _ = conn.Close() + } + } + s.server.ForEachConn(disconnectUser) + } +} diff --git a/internal/smtp/smtp.go b/internal/smtp/smtp.go new file mode 100644 index 00000000..9ca0f416 --- /dev/null +++ b/internal/smtp/smtp.go @@ -0,0 +1,25 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Package smtp provides SMTP server of the Bridge. +package smtp + +import "github.com/ProtonMail/proton-bridge/pkg/config" + +var ( + log = config.GetLogEntry("smtp") //nolint[gochecknoglobals] +) diff --git a/internal/smtp/store.go b/internal/smtp/store.go new file mode 100644 index 00000000..aeee30c6 --- /dev/null +++ b/internal/smtp/store.go @@ -0,0 +1,36 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package smtp + +import ( + "io" + + pmcrypto "github.com/ProtonMail/gopenpgp/crypto" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" +) + +type storeUserProvider interface { + CreateDraft( + kr *pmcrypto.KeyRing, + message *pmapi.Message, + attachmentReaders []io.Reader, + attachedPublicKey, + attachedPublicKeyName string, + parentID string) (*pmapi.Message, []*pmapi.Attachment, error) + SendMessage(messageID string, req *pmapi.SendMessageReq) error +} diff --git a/internal/smtp/user.go b/internal/smtp/user.go new file mode 100644 index 00000000..e04ebc5a --- /dev/null +++ b/internal/smtp/user.go @@ -0,0 +1,513 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// NOTE: Comments in this file refer to a specification in a document called "ProtonMail Encryption logic". It will be referred to via abbreviation PMEL. + +package smtp + +import ( + "bytes" + "encoding/base64" + "fmt" + "io" + "math/rand" + "mime" + "net/mail" + "regexp" + "strconv" + "strings" + "time" + + pmcrypto "github.com/ProtonMail/gopenpgp/crypto" + "github.com/ProtonMail/proton-bridge/internal/bridge" + "github.com/ProtonMail/proton-bridge/internal/events" + "github.com/ProtonMail/proton-bridge/pkg/listener" + "github.com/ProtonMail/proton-bridge/pkg/message" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + goSMTPBackend "github.com/emersion/go-smtp" + "github.com/pkg/errors" +) + +type smtpUser struct { + panicHandler panicHandler + eventListener listener.Listener + backend *smtpBackend + user bridgeUser + client bridge.PMAPIProvider + storeUser storeUserProvider + addressID string +} + +// newSMTPUser returns struct implementing go-smtp/session interface. +func newSMTPUser( + panicHandler panicHandler, + eventListener listener.Listener, + smtpBackend *smtpBackend, + user bridgeUser, + addressID string, +) (goSMTPBackend.User, error) { + // Using client directly is deprecated. Code should be moved to store. + client := user.GetTemporaryPMAPIClient() + + storeUser := user.GetStore() + if storeUser == nil { + return nil, errors.New("user database is not initialized") + } + + return &smtpUser{ + panicHandler: panicHandler, + eventListener: eventListener, + backend: smtpBackend, + user: user, + client: client, + storeUser: storeUser, + addressID: addressID, + }, nil +} + +// Send sends an email from the given address to the given addresses with the given body. +func (su *smtpUser) Send(from string, to []string, messageReader io.Reader) (err error) { //nolint[funlen] + // Called from go-smtp in goroutines - we need to handle panics for each function. + defer su.panicHandler.HandlePanic() + + mailSettings, err := su.client.GetMailSettings() + if err != nil { + return err + } + + var addr *pmapi.Address = su.client.Addresses().ByEmail(from) + if addr == nil { + err = errors.New("backend: invalid email address: not owned by user") + return + } + kr := addr.KeyRing() + + var attachedPublicKey string + var attachedPublicKeyName string + if mailSettings.AttachPublicKey > 0 { + attachedPublicKey, err = kr.GetArmoredPublicKey() + if err != nil { + return err + } + attachedPublicKeyName = "publickey - " + kr.Identities()[0].Name + } + + message, mimeBody, plainBody, attReaders, err := message.Parse(messageReader, attachedPublicKey, attachedPublicKeyName) + if err != nil { + return + } + clearBody := message.Body + + externalID := message.Header.Get("Message-Id") + externalID = strings.Trim(externalID, "<>") + + draftID, parentID := su.handleReferencesHeader(message) + + if err = su.handleSenderAndRecipients(message, addr, from, to); err != nil { + return err + } + + message.AddressID = addr.ID + + // Apple Mail Message-Id has to be stored to avoid recovered message after each send. + // Before it was done only for Apple Mail, but it should work for any client. Also, the client + // is set up from IMAP and no one can be sure that the same client is used for SMTP as well. + // Also, user can use more than one client which could break the condition as well. + // If there is any problem, condition to Apple Mail only should be returned. + // Note: for that, we would need to refactor a little bit and pass the last client name from + // the IMAP through the bridge user. + message.ExternalID = externalID + + // If Outlook does not get a response quickly, it will try to send the message again, leading + // to sending the same message multiple times. In case we detect the same message is in the + // sending queue, we wait a minute to finish the first request. If the message is still being + // sent after the timeout, we return an error back to the client. The UX is not the best, + // but it's better than sending the message many times. If the message was sent, we simply return + // nil to indicate it's OK. + sendRecorderMessageHash := su.backend.sendRecorder.getMessageHash(message) + isSending, wasSent := su.backend.sendRecorder.isSendingOrSent(su.client, sendRecorderMessageHash) + if isSending { + log.Debug("Message is in send queue, waiting") + time.Sleep(60 * time.Second) + isSending, wasSent = su.backend.sendRecorder.isSendingOrSent(su.client, sendRecorderMessageHash) + } + if isSending { + log.Debug("Message is still in send queue, returning error") + return errors.New("message is sending") + } + if wasSent { + log.Debug("Message was already sent") + return nil + } + + message, atts, err := su.storeUser.CreateDraft(kr, message, attReaders, attachedPublicKey, attachedPublicKeyName, parentID) + if err != nil { + return + } + su.backend.sendRecorder.addMessage(sendRecorderMessageHash, message.ID) + + // We always have to create a new draft even if there already is one, + // because clients don't necessarily save the draft before sending, which + // can lead to sending the wrong message. Also clients do not necessarily + // delete the old draft. + if draftID != "" { + if err := su.client.DeleteMessages([]string{draftID}); err != nil { + log.WithError(err).WithField("draftID", draftID).Warn("Original draft cannot be deleted") + } + } + + atts = append(atts, message.Attachments...) + // Decrypt attachment keys, because we will need to re-encrypt them with the recipients' public keys. + attkeys := make(map[string]*pmcrypto.SymmetricKey) + attkeysEncoded := make(map[string]pmapi.AlgoKey) + + for _, att := range atts { + var keyPackets []byte + if keyPackets, err = base64.StdEncoding.DecodeString(att.KeyPackets); err != nil { + return errors.Wrap(err, "decoding attachment key packets") + } + if attkeys[att.ID], err = kr.DecryptSessionKey(keyPackets); err != nil { + return errors.Wrap(err, "decrypting attachment session key") + } + attkeysEncoded[att.ID] = pmapi.AlgoKey{ + Key: attkeys[att.ID].GetBase64Key(), + Algorithm: attkeys[att.ID].Algo, + } + } + + plainSharedScheme := 0 + htmlSharedScheme := 0 + mimeSharedType := 0 + + plainAddressMap := make(map[string]*pmapi.MessageAddress) + htmlAddressMap := make(map[string]*pmapi.MessageAddress) + mimeAddressMap := make(map[string]*pmapi.MessageAddress) + + // PMEL 2. + settingsPgpScheme := mailSettings.PGPScheme + settingsSign := (mailSettings.Sign > 0) + + // PMEL 3. + composeMode := message.MIMEType + + var plainKey, htmlKey, mimeKey *pmcrypto.SymmetricKey + var plainData, htmlData, mimeData []byte + + containsUnencryptedRecipients := false + + for _, email := range to { + // PMEL 1. + contactEmails, err := su.client.GetContactEmailByEmail(email, 0, 1000) + if err != nil { + return err + } + var contactMeta *ContactMetadata + var contactKeys []*pmcrypto.KeyRing + for _, contactEmail := range contactEmails { + if contactEmail.Defaults == 1 { // WARNING: in doc it says _ignore for now, future feature_ + continue + } + contact, err := su.client.GetContactByID(contactEmail.ContactID) + if err != nil { + return err + } + decryptedCards, err := su.client.DecryptAndVerifyCards(contact.Cards) + if err != nil { + return err + } + contactMeta, err = GetContactMetadataFromVCards(decryptedCards, email) + if err != nil { + return err + } + for _, contactRawKey := range contactMeta.Keys { + contactKey, err := pmcrypto.ReadKeyRing(bytes.NewBufferString(contactRawKey)) + if err != nil { + return err + } + contactKeys = append(contactKeys, contactKey) + } + + break // We take the first hit where Defaults == 0, see "How to find the right contact" of PMEL + } + + // PMEL 4. + apiRawKeyList, isInternal, err := su.client.GetPublicKeysForEmail(email) + if err != nil { + err = fmt.Errorf("backend: cannot get recipients' public keys: %v", err) + return err + } + + var apiKeys []*pmcrypto.KeyRing + for _, apiRawKey := range apiRawKeyList { + var kr *pmcrypto.KeyRing + if kr, err = pmcrypto.ReadArmoredKeyRing(strings.NewReader(apiRawKey.PublicKey)); err != nil { + return err + } + apiKeys = append(apiKeys, kr) + } + + sendingInfo, err := generateSendingInfo(su.eventListener, contactMeta, isInternal, composeMode, apiKeys, contactKeys, settingsSign, settingsPgpScheme) + if !sendingInfo.Encrypt { + containsUnencryptedRecipients = true + } + if err != nil { + return errors.New("error sending to user " + email + ": " + err.Error()) + } + + var signature int + if sendingInfo.Sign { + signature = pmapi.YesSignature + } else { + signature = pmapi.NoSignature + } + if sendingInfo.Scheme == pmapi.PGPMIMEPackage || sendingInfo.Scheme == pmapi.ClearMIMEPackage { + if mimeKey == nil { + if mimeKey, mimeData, err = encryptSymmetric(kr, mimeBody, true); err != nil { + return err + } + } + if sendingInfo.Scheme == pmapi.PGPMIMEPackage { + mimeBodyPacket, _, err := createPackets(sendingInfo.PublicKey, mimeKey, map[string]*pmcrypto.SymmetricKey{}) + if err != nil { + return err + } + mimeAddressMap[email] = &pmapi.MessageAddress{Type: sendingInfo.Scheme, BodyKeyPacket: mimeBodyPacket, Signature: signature} + } else { + mimeAddressMap[email] = &pmapi.MessageAddress{Type: sendingInfo.Scheme, Signature: signature} + } + mimeSharedType |= sendingInfo.Scheme + } else { + switch sendingInfo.MIMEType { + case pmapi.ContentTypePlainText: + if plainKey == nil { + if plainKey, plainData, err = encryptSymmetric(kr, plainBody, true); err != nil { + return err + } + } + newAddress := &pmapi.MessageAddress{Type: sendingInfo.Scheme, Signature: signature} + if sendingInfo.Encrypt && sendingInfo.PublicKey != nil { + newAddress.BodyKeyPacket, newAddress.AttachmentKeyPackets, err = createPackets(sendingInfo.PublicKey, plainKey, attkeys) + if err != nil { + return err + } + } + plainAddressMap[email] = newAddress + plainSharedScheme |= sendingInfo.Scheme + case pmapi.ContentTypeHTML: + if htmlKey == nil { + if htmlKey, htmlData, err = encryptSymmetric(kr, clearBody, true); err != nil { + return err + } + } + newAddress := &pmapi.MessageAddress{Type: sendingInfo.Scheme, Signature: signature} + if sendingInfo.Encrypt && sendingInfo.PublicKey != nil { + newAddress.BodyKeyPacket, newAddress.AttachmentKeyPackets, err = createPackets(sendingInfo.PublicKey, htmlKey, attkeys) + if err != nil { + return err + } + } + htmlAddressMap[email] = newAddress + htmlSharedScheme |= sendingInfo.Scheme + } + } + } + + if containsUnencryptedRecipients { + dec := new(mime.WordDecoder) + subject, err := dec.DecodeHeader(message.Header.Get("Subject")) + if err != nil { + return errors.New("error decoding subject message " + message.Header.Get("Subject")) + } + if !su.continueSendingUnencryptedMail(subject) { + _ = su.client.DeleteMessages([]string{message.ID}) + return errors.New("sending was canceled by user") + } + } + + req := &pmapi.SendMessageReq{} + + plainPkg := buildPackage(plainAddressMap, plainSharedScheme, pmapi.ContentTypePlainText, plainData, plainKey, attkeysEncoded) + if plainPkg != nil { + req.Packages = append(req.Packages, plainPkg) + } + htmlPkg := buildPackage(htmlAddressMap, htmlSharedScheme, pmapi.ContentTypeHTML, htmlData, htmlKey, attkeysEncoded) + if htmlPkg != nil { + req.Packages = append(req.Packages, htmlPkg) + } + + if len(mimeAddressMap) > 0 { + pkg := &pmapi.MessagePackage{ + Body: base64.StdEncoding.EncodeToString(mimeData), + Addresses: mimeAddressMap, + MIMEType: pmapi.ContentTypeMultipartMixed, + Type: mimeSharedType, + BodyKey: pmapi.AlgoKey{ + Key: mimeKey.GetBase64Key(), + Algorithm: mimeKey.Algo, + }, + } + req.Packages = append(req.Packages, pkg) + } + + return su.storeUser.SendMessage(message.ID, req) +} + +func (su *smtpUser) handleReferencesHeader(m *pmapi.Message) (draftID, parentID string) { + // Remove the internal IDs from the references header before sending to avoid confusion. + references := m.Header.Get("References") + newReferences := []string{} + for _, reference := range strings.Fields(references) { + if !strings.Contains(reference, "@protonmail.internalid") { + newReferences = append(newReferences, reference) + } else { // internalid is the parentID. + idMatch := regexp.MustCompile("[a-zA-Z0-9-_=]*@protonmail.internalid").FindString(reference) + if idMatch != "" { + lastID := idMatch[0 : len(idMatch)-len("@protonmail.internalid")] + filter := &pmapi.MessagesFilter{ID: []string{lastID}} + if su.addressID != "" { + filter.AddressID = su.addressID + } + metadata, _, _ := su.client.ListMessages(filter) + for _, m := range metadata { + if isDraft(m) { + draftID = m.ID + } else { + parentID = m.ID + } + } + } + } + } + + m.Header["References"] = newReferences + + if parentID == "" && len(newReferences) > 0 { + externalID := strings.Trim(newReferences[len(newReferences)-1], "<>") + filter := &pmapi.MessagesFilter{ExternalID: externalID} + if su.addressID != "" { + filter.AddressID = su.addressID + } + metadata, _, _ := su.client.ListMessages(filter) + // There can be two or messages with the same external ID and then we cannot + // be sure which message should be parent. Better to not choose any. + if len(metadata) == 1 { + parentID = metadata[0].ID + } + } + + return draftID, parentID +} + +func isDraft(m *pmapi.Message) bool { + for _, labelID := range m.LabelIDs { + if labelID == pmapi.DraftLabel { + return true + } + } + return false +} + +func (su *smtpUser) handleSenderAndRecipients(m *pmapi.Message, addr *pmapi.Address, from string, to []string) (err error) { + from = pmapi.ConstructAddress(from, addr.Email) + + // Check sender. + if m.Sender == nil { + m.Sender = &mail.Address{Address: from} + } else { + m.Sender.Address = from + } + + // Check recipients. + if len(to) == 0 { + err = errors.New("backend: no recipient specified") + return + } + + // Sanitize ToList because some clients add *Sender* in the *ToList* when only Bcc is filled. + i := 0 + for _, keep := range m.ToList { + keepThis := false + for _, addr := range to { + if addr == keep.Address { + keepThis = true + break + } + } + if keepThis { + m.ToList[i] = keep + i++ + } + } + m.ToList = m.ToList[:i] + + // Build a map of recipients visible to all. + // Bcc should be empty when sending a message. + var recipients []*mail.Address + recipients = append(recipients, m.ToList...) + recipients = append(recipients, m.CCList...) + recipients = append(recipients, m.BCCList...) + + rm := map[string]bool{} + for _, r := range recipients { + rm[r.Address] = true + } + + for _, r := range to { + if !rm[r] { + // Recipient is not known, add it to Bcc. + m.BCCList = append(m.BCCList, &mail.Address{Address: r}) + } + } + + return nil +} + +func (su *smtpUser) continueSendingUnencryptedMail(subject string) bool { + if !su.backend.shouldReportOutgoingNoEnc() { + return true + } + + messageID := strconv.Itoa(rand.Int()) //nolint[gosec] + ch := make(chan bool) + su.backend.shouldSendNoEncChannels[messageID] = ch + su.eventListener.Emit(events.OutgoingNoEncEvent, messageID+":"+subject) + + log.Debug("Waiting for sendingUnencrypted confirmation for ", messageID) + + var res bool + select { + case res = <-ch: + // GUI should always respond in 10 seconds, but let's have safety timeout + // in case GUI will not respond properly. If GUI didn't respond, we cannot + // be sure if user even saw the notice: better to not send the e-mail. + log.Debug("Got sendingUnencrypted for ", messageID, ": ", res) + case <-time.After(15 * time.Second): + log.Debug("sendingUnencrypted timeout, not sending ", messageID) + res = false + } + + delete(su.backend.shouldSendNoEncChannels, messageID) + close(ch) + + return res +} + +// Logout is called when this User will no longer be used. +func (su *smtpUser) Logout() error { + log.Debug("SMTP client logged out user ", su.addressID) + return nil +} diff --git a/internal/smtp/utils.go b/internal/smtp/utils.go new file mode 100644 index 00000000..263019e9 --- /dev/null +++ b/internal/smtp/utils.go @@ -0,0 +1,96 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package smtp + +import ( + "encoding/base64" + + pmcrypto "github.com/ProtonMail/gopenpgp/crypto" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" +) + +func createPackets( + pubkey *pmcrypto.KeyRing, + bodyKey *pmcrypto.SymmetricKey, + attkeys map[string]*pmcrypto.SymmetricKey, +) (bodyPacket string, attachmentPackets map[string]string, err error) { + // Encrypt message body keys. + packetBytes, err := pubkey.EncryptSessionKey(bodyKey) + if err != nil { + return + } + bodyPacket = base64.StdEncoding.EncodeToString(packetBytes) + + // Encrypt attachment keys. + attachmentPackets = make(map[string]string) + for id, attkey := range attkeys { + var packets []byte + if packets, err = pubkey.EncryptSessionKey(attkey); err != nil { + return + } + attachmentPackets[id] = base64.StdEncoding.EncodeToString(packets) + } + return +} + +func encryptSymmetric( + kr *pmcrypto.KeyRing, + textToEncrypt string, + canonicalizeText bool, // nolint[unparam] +) (key *pmcrypto.SymmetricKey, symEncryptedData []byte, err error) { + // We use only primary key to encrypt the message. Our keyring contains all keys (primary, old and deacivated ones). + pgpMessage, err := kr.FirstKey().Encrypt(pmcrypto.NewPlainMessageFromString(textToEncrypt), kr) + if err != nil { + return + } + pgpSplitMessage, err := pgpMessage.SeparateKeyAndData(len(textToEncrypt), 0) + if err != nil { + return + } + key, err = kr.DecryptSessionKey(pgpSplitMessage.GetBinaryKeyPacket()) + if err != nil { + return + } + symEncryptedData = pgpSplitMessage.GetBinaryDataPacket() + return +} + +func buildPackage( + addressMap map[string]*pmapi.MessageAddress, + sharedScheme int, + mimeType string, + bodyData []byte, + bodyKey *pmcrypto.SymmetricKey, + attKeys map[string]pmapi.AlgoKey, +) (pkg *pmapi.MessagePackage) { + if len(addressMap) == 0 { + return nil + } + pkg = &pmapi.MessagePackage{ + Body: base64.StdEncoding.EncodeToString(bodyData), + Addresses: addressMap, + MIMEType: mimeType, + Type: sharedScheme, + } + if sharedScheme|pmapi.ClearPackage > 0 { + pkg.BodyKey.Key = bodyKey.GetBase64Key() + pkg.BodyKey.Algorithm = bodyKey.Algo + pkg.AttachmentKeys = attKeys + } + return pkg +} diff --git a/internal/smtp/vcard_tools.go b/internal/smtp/vcard_tools.go new file mode 100644 index 00000000..e84456c9 --- /dev/null +++ b/internal/smtp/vcard_tools.go @@ -0,0 +1,94 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package smtp + +import ( + "encoding/base64" + "strconv" + "strings" + + "github.com/ProtonMail/go-vcard" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" +) + +type ContactMetadata struct { + Email string + Keys []string + Scheme string + Sign bool + SignMissing bool + Encrypt bool + MIMEType string +} + +const ( + FieldPMScheme = "X-PM-SCHEME" + FieldPMEncrypt = "X-PM-ENCRYPT" + FieldPMSign = "X-PM-SIGN" + FieldPMMIMEType = "X-PM-MIMETYPE" +) + +func GetContactMetadataFromVCards(cards []pmapi.Card, email string) (contactMeta *ContactMetadata, err error) { + for _, card := range cards { + dec := vcard.NewDecoder(strings.NewReader(card.Data)) + parsedCard, err := dec.Decode() + if err != nil { + return nil, err + } + group := parsedCard.GetGroupByValue(vcard.FieldEmail, email) + if len(group) == 0 { + continue + } + + keys := []string{} + for _, key := range parsedCard.GetAllValueByGroup(vcard.FieldKey, group) { + keybyte, err := base64.StdEncoding.DecodeString(strings.Split(key, "base64,")[1]) + if err != nil { + return nil, err + } + // It would be better to always have correct data on the server, but mistakes + // can happen -- we had an issue where KEY was included in VCARD, but was empty. + // It's valid and we need to handle it by not including it in the keys, which would fail later. + if len(keybyte) > 0 { + keys = append(keys, string(keybyte)) + } + } + scheme := parsedCard.GetValueByGroup(FieldPMScheme, group) + // Warn: ParseBool treats 1, T, True, true as true and 0, F, Fale, false as false. + // However PMEL declares 'true' is true, 'false' is false. every other string is true + encrypt, _ := strconv.ParseBool(parsedCard.GetValueByGroup(FieldPMEncrypt, group)) + var sign, signMissing bool + if len(parsedCard[FieldPMSign]) == 0 { + signMissing = true + } else { + sign, _ = strconv.ParseBool(parsedCard.GetValueByGroup(FieldPMSign, group)) + signMissing = false + } + mimeType := parsedCard.GetValueByGroup(FieldPMMIMEType, group) + return &ContactMetadata{ + Email: email, + Keys: keys, + Scheme: scheme, + Sign: sign, + SignMissing: signMissing, + Encrypt: encrypt, + MIMEType: mimeType, + }, nil + } + return &ContactMetadata{}, nil +} diff --git a/internal/store/address.go b/internal/store/address.go new file mode 100644 index 00000000..87727526 --- /dev/null +++ b/internal/store/address.go @@ -0,0 +1,109 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/sirupsen/logrus" +) + +// Address holds mailboxes for IMAP user (login address). In combined mode +// there is only one address, in split mode there is one object per address. +type Address struct { + store *Store + address string + addressID string + mailboxes map[string]*Mailbox + + log *logrus.Entry +} + +func newAddress( + store *Store, + address, addressID string, + labels []*pmapi.Label, +) (addr *Address, err error) { + l := log.WithField("addressID", addressID) + + storeAddress := &Address{ + store: store, + address: address, + addressID: addressID, + log: l, + } + + if err = storeAddress.init(labels); err != nil { + l.WithField("address", address). + WithError(err). + Error("Could not initialise store address") + + return + } + + return storeAddress, nil +} + +func (storeAddress *Address) init(foldersAndLabels []*pmapi.Label) (err error) { + storeAddress.log.WithField("address", storeAddress.address).Debug("Initialising store address") + + storeAddress.mailboxes = make(map[string]*Mailbox) + + for _, label := range foldersAndLabels { + prefix := getLabelPrefix(label) + + var mailbox *Mailbox + if mailbox, err = newMailbox(storeAddress, label.ID, prefix, label.Name, label.Color); err != nil { + storeAddress.log. + WithError(err). + WithField("labelID", label.ID). + Error("Could not init mailbox for folder or label") + return + } + + storeAddress.mailboxes[label.ID] = mailbox + } + + return +} + +// getLabelPrefix returns the correct prefix for a pmapi label according to whether it is exclusive or not. +func getLabelPrefix(l *pmapi.Label) string { + switch { + case pmapi.IsSystemLabel(l.ID): + return "" + case l.Exclusive == 1: + return UserFoldersPrefix + default: + return UserLabelsPrefix + } +} + +// AddressString returns the address. +func (storeAddress *Address) AddressString() string { + return storeAddress.address +} + +// AddressID returns the address ID. +func (storeAddress *Address) AddressID() string { + return storeAddress.addressID +} + +// APIAddress returns the `pmapi.Address` struct. +func (storeAddress *Address) APIAddress() *pmapi.Address { + return storeAddress.store.api.Addresses().ByEmail(storeAddress.address) +} diff --git a/internal/store/address_mailbox.go b/internal/store/address_mailbox.go new file mode 100644 index 00000000..eae9ab5c --- /dev/null +++ b/internal/store/address_mailbox.go @@ -0,0 +1,106 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "fmt" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" +) + +// ListMailboxes returns all mailboxes. +func (storeAddress *Address) ListMailboxes() []*Mailbox { + storeAddress.store.lock.RLock() + defer storeAddress.store.lock.RUnlock() + + mailboxes := make([]*Mailbox, 0, len(storeAddress.mailboxes)) + for _, m := range storeAddress.mailboxes { + mailboxes = append(mailboxes, m) + } + return mailboxes +} + +// GetMailbox returns mailbox with the given IMAP name. +func (storeAddress *Address) GetMailbox(name string) (*Mailbox, error) { + storeAddress.store.lock.RLock() + defer storeAddress.store.lock.RUnlock() + + for _, m := range storeAddress.mailboxes { + if m.Name() == name { + return m, nil + } + } + + return nil, fmt.Errorf("mailbox %v does not exist", name) +} + +// CreateMailbox creates the mailbox by calling an API. +// Mailbox is created in the structure by processing event. +func (storeAddress *Address) CreateMailbox(name string) error { + return storeAddress.store.createMailbox(name) +} + +// updateMailbox updates the mailbox by calling an API. +// Mailbox is updated in the structure by processing event. +func (storeAddress *Address) updateMailbox(labelID, newName, color string) error { + return storeAddress.store.updateMailbox(labelID, newName, color) +} + +// deleteMailbox deletes the mailbox by calling an API. +// Mailbox is deleted in the structure by processing event. +func (storeAddress *Address) deleteMailbox(labelID string) error { + return storeAddress.store.deleteMailbox(labelID, storeAddress.addressID) +} + +// createOrUpdateMailboxEvent creates or updates the mailbox in the structure. +// This is called from the event loop. +func (storeAddress *Address) createOrUpdateMailboxEvent(label *pmapi.Label) error { + prefix := getLabelPrefix(label) + mailbox, ok := storeAddress.mailboxes[label.ID] + if !ok { + mailbox, err := newMailbox(storeAddress, label.ID, prefix, label.Name, label.Color) + if err != nil { + return err + } + storeAddress.mailboxes[label.ID] = mailbox + } else { + mailbox.labelName = prefix + label.Name + mailbox.color = label.Color + } + return nil +} + +// deleteMailboxEvent deletes the mailbox in the structure. +// This is called from the event loop. +func (storeAddress *Address) deleteMailboxEvent(labelID string) error { + storeMailbox, ok := storeAddress.mailboxes[labelID] + if !ok { + log.WithField("labelID", labelID).Warn("Could not find mailbox to delete") + return nil + } + delete(storeAddress.mailboxes, labelID) + return storeMailbox.deleteMailboxEvent() +} + +func (storeAddress *Address) getMailboxByID(labelID string) (*Mailbox, error) { + storeMailbox, ok := storeAddress.mailboxes[labelID] + if !ok { + return nil, fmt.Errorf("mailbox with id %q does not exist", labelID) + } + return storeMailbox, nil +} diff --git a/internal/store/address_message.go b/internal/store/address_message.go new file mode 100644 index 00000000..94cebe39 --- /dev/null +++ b/internal/store/address_message.go @@ -0,0 +1,42 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + bolt "go.etcd.io/bbolt" +) + +func (storeAddress *Address) txCreateOrUpdateMessages(tx *bolt.Tx, msgs []*pmapi.Message) error { + for _, m := range storeAddress.mailboxes { + if err := m.txCreateOrUpdateMessages(tx, msgs); err != nil { + return err + } + } + return nil +} + +// txDeleteMessage deletes the message from the mailbox buckets for this address. +func (storeAddress *Address) txDeleteMessage(tx *bolt.Tx, apiID string) error { + for _, m := range storeAddress.mailboxes { + if err := m.txDeleteMessage(tx, apiID); err != nil { + return err + } + } + return nil +} diff --git a/internal/store/cache.go b/internal/store/cache.go new file mode 100644 index 00000000..314044dd --- /dev/null +++ b/internal/store/cache.go @@ -0,0 +1,114 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "encoding/json" + "os" + "sync" + + "github.com/pkg/errors" +) + +// Cache caches the last event IDs for all accounts (there should be only one instance). +type Cache struct { + // cache is map from userID => key (such as last event) => value (such as event ID). + cache map[string]map[string]string + path string + lock *sync.RWMutex +} + +// NewCache constructs a new cache at the given path. +func NewCache(path string) *Cache { + return &Cache{ + path: path, + lock: &sync.RWMutex{}, + } +} + +func (c *Cache) getEventID(userID string) string { + c.lock.Lock() + defer c.lock.Unlock() + + _ = c.loadCache() + + if c.cache == nil { + c.cache = map[string]map[string]string{} + } + if c.cache[userID] == nil { + c.cache[userID] = map[string]string{} + } + + return c.cache[userID]["events"] +} + +func (c *Cache) setEventID(userID, eventID string) error { + c.lock.Lock() + defer c.lock.Unlock() + + if c.cache[userID] == nil { + c.cache[userID] = map[string]string{} + } + c.cache[userID]["events"] = eventID + + return c.saveCache() +} + +func (c *Cache) loadCache() error { + if c.cache != nil { + return nil + } + + f, err := os.Open(c.path) + if err != nil { + return err + } + defer f.Close() //nolint[errcheck] + + return json.NewDecoder(f).Decode(&c.cache) +} + +func (c *Cache) saveCache() error { + if c.cache == nil { + return errors.New("events: cannot save cache: cache is nil") + } + + f, err := os.Create(c.path) + if err != nil { + return err + } + defer f.Close() //nolint[errcheck] + + return json.NewEncoder(f).Encode(c.cache) +} + +func (c *Cache) clearCacheUser(userID string) error { + c.lock.Lock() + defer c.lock.Unlock() + + if c.cache == nil { + log.WithField("user", userID).Warning("Cannot clear user from cache: cache is nil") + return nil + } + + log.WithField("user", userID).Trace("Removing user from event loop cache") + + delete(c.cache, userID) + + return c.saveCache() +} diff --git a/internal/store/change.go b/internal/store/change.go new file mode 100644 index 00000000..319fea36 --- /dev/null +++ b/internal/store/change.go @@ -0,0 +1,109 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "time" + + "github.com/ProtonMail/proton-bridge/pkg/message" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + imap "github.com/emersion/go-imap" + imapBackend "github.com/emersion/go-imap/backend" + "github.com/sirupsen/logrus" +) + +// SetIMAPUpdateChannel sets the channel on which imap update messages will be sent. This should be the channel +// on which the imap backend listens for imap updates. +func (store *Store) SetIMAPUpdateChannel(updates chan interface{}) { + store.log.Debug("Listening for IMAP updates") + + if store.imapUpdates = updates; store.imapUpdates == nil { + store.log.Error("The IMAP Updates channel is nil") + } +} + +func (store *Store) imapNotice(address, notice string) { + update := new(imapBackend.StatusUpdate) + update.Username = address + update.StatusResp = &imap.StatusResp{ + Type: imap.StatusOk, + Code: imap.CodeAlert, + Info: notice, + } + store.imapSendUpdate(update) +} + +func (store *Store) imapUpdateMessage(address, mailboxName string, uid, sequenceNumber uint32, msg *pmapi.Message) { + store.log.WithFields(logrus.Fields{ + "address": address, + "mailbox": mailboxName, + "seqNum": sequenceNumber, + "uid": uid, + "flags": message.GetFlags(msg), + }).Trace("IDLE update") + update := new(imapBackend.MessageUpdate) + update.Username = address + update.Mailbox = mailboxName + update.Message = imap.NewMessage(sequenceNumber, []string{imap.FlagsMsgAttr, imap.UidMsgAttr}) + update.Message.Flags = message.GetFlags(msg) + update.Message.Uid = uid + store.imapSendUpdate(update) +} + +func (store *Store) imapDeleteMessage(address, mailboxName string, sequenceNumber uint32) { + store.log.WithFields(logrus.Fields{ + "address": address, + "mailbox": mailboxName, + "seqNum": sequenceNumber, + }).Trace("IDLE delete") + update := new(imapBackend.ExpungeUpdate) + update.Username = address + update.Mailbox = mailboxName + update.SeqNum = sequenceNumber + store.imapSendUpdate(update) +} + +func (store *Store) imapMailboxStatus(address, mailboxName string, total, unread uint) { + store.log.WithFields(logrus.Fields{ + "address": address, + "mailbox": mailboxName, + "total": total, + "unread": unread, + }).Trace("IDLE status") + update := new(imapBackend.MailboxUpdate) + update.Username = address + update.Mailbox = mailboxName + update.MailboxStatus = imap.NewMailboxStatus(mailboxName, []string{imap.MailboxMessages, imap.MailboxUnseen}) + update.MailboxStatus.Messages = uint32(total) + update.MailboxStatus.Unseen = uint32(unread) + store.imapSendUpdate(update) +} + +func (store *Store) imapSendUpdate(update interface{}) { + if store.imapUpdates == nil { + store.log.Trace("IMAP IDLE unavailable") + return + } + + select { + case <-time.After(1 * time.Second): + store.log.Error("Could not send IMAP update (timeout)") + return + case store.imapUpdates <- update: + } +} diff --git a/internal/store/change_test.go b/internal/store/change_test.go new file mode 100644 index 00000000..377205fb --- /dev/null +++ b/internal/store/change_test.go @@ -0,0 +1,129 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "testing" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + imapBackend "github.com/emersion/go-imap/backend" + "github.com/stretchr/testify/require" +) + +func TestCreateOrUpdateMessageIMAPUpdates(t *testing.T) { + m, clear := initMocks(t) + defer clear() + + updates := make(chan interface{}) + + m.newStoreNoEvents(true) + m.store.SetIMAPUpdateChannel(updates) + + go checkIMAPUpdates(t, updates, []func(interface{}) bool{ + checkMessageUpdate(addr1, "All Mail", 1, 1), + checkMessageUpdate(addr1, "All Mail", 2, 2), + }) + + insertMessage(t, m, "msg1", "Test message 1", addrID1, 0, []string{pmapi.AllMailLabel}) + insertMessage(t, m, "msg2", "Test message 2", addrID1, 0, []string{pmapi.AllMailLabel}) + + close(updates) +} + +func TestCreateOrUpdateMessageIMAPUpdatesBulkUpdate(t *testing.T) { + m, clear := initMocks(t) + defer clear() + + updates := make(chan interface{}) + + m.newStoreNoEvents(true) + m.store.SetIMAPUpdateChannel(updates) + + go checkIMAPUpdates(t, updates, []func(interface{}) bool{ + checkMessageUpdate(addr1, "All Mail", 1, 1), + checkMessageUpdate(addr1, "All Mail", 2, 2), + }) + + msg1 := getTestMessage("msg1", "Test message 1", addrID1, 0, []string{pmapi.AllMailLabel}) + msg2 := getTestMessage("msg2", "Test message 2", addrID1, 0, []string{pmapi.AllMailLabel}) + require.Nil(t, m.store.createOrUpdateMessagesEvent([]*pmapi.Message{msg1, msg2})) + + close(updates) +} + +func TestDeleteMessageIMAPUpdate(t *testing.T) { + m, clear := initMocks(t) + defer clear() + + m.newStoreNoEvents(true) + + insertMessage(t, m, "msg1", "Test message 1", addrID1, 0, []string{pmapi.AllMailLabel}) + insertMessage(t, m, "msg2", "Test message 2", addrID1, 0, []string{pmapi.AllMailLabel}) + + updates := make(chan interface{}) + m.store.SetIMAPUpdateChannel(updates) + go checkIMAPUpdates(t, updates, []func(interface{}) bool{ + checkMessageDelete(addr1, "All Mail", 2), + checkMessageDelete(addr1, "All Mail", 1), + }) + + require.Nil(t, m.store.deleteMessageEvent("msg2")) + require.Nil(t, m.store.deleteMessageEvent("msg1")) + close(updates) +} + +func checkIMAPUpdates(t *testing.T, updates chan interface{}, checkFunctions []func(interface{}) bool) { + idx := 0 + for update := range updates { + if idx >= len(checkFunctions) { + continue + } + if !checkFunctions[idx](update) { + continue + } + idx++ + } + require.True(t, idx == len(checkFunctions), "Less updates than expected: %+v of %+v", idx, len(checkFunctions)) +} + +func checkMessageUpdate(username, mailbox string, seqNum, uid int) func(interface{}) bool { //nolint[unparam] + return func(update interface{}) bool { + switch u := update.(type) { + case *imapBackend.MessageUpdate: + return (u.Update.Username == username && + u.Update.Mailbox == mailbox && + u.Message.SeqNum == uint32(seqNum) && + u.Message.Uid == uint32(uid)) + default: + return false + } + } +} + +func checkMessageDelete(username, mailbox string, seqNum int) func(interface{}) bool { //nolint[unparam] + return func(update interface{}) bool { + switch u := update.(type) { + case *imapBackend.ExpungeUpdate: + return (u.Update.Username == username && + u.Update.Mailbox == mailbox && + u.SeqNum == uint32(seqNum)) + default: + return false + } + } +} diff --git a/internal/store/convert.go b/internal/store/convert.go new file mode 100644 index 00000000..34503e85 --- /dev/null +++ b/internal/store/convert.go @@ -0,0 +1,32 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import "encoding/binary" + +// itob returns a 4-byte big endian representation of v. +func itob(v uint32) []byte { + b := make([]byte, 4) + binary.BigEndian.PutUint32(b, v) + return b +} + +// btoi returns the uint32 represented by b. +func btoi(b []byte) uint32 { + return binary.BigEndian.Uint32(b) +} diff --git a/internal/store/event_loop.go b/internal/store/event_loop.go new file mode 100644 index 00000000..3d1f1bd3 --- /dev/null +++ b/internal/store/event_loop.go @@ -0,0 +1,546 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "time" + + bridgeEvents "github.com/ProtonMail/proton-bridge/internal/events" + "github.com/ProtonMail/proton-bridge/pkg/listener" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" +) + +const pollInterval = 30 * time.Second + +type eventLoop struct { + cache *Cache + currentEventID string + pollCh chan chan struct{} + stopCh chan struct{} + notifyStopCh chan struct{} + isRunning bool + hasInternet bool + + log *logrus.Entry + + store *Store + apiClient PMAPIProvider + user BridgeUser + events listener.Listener +} + +func newEventLoop(cache *Cache, store *Store, api PMAPIProvider, user BridgeUser, events listener.Listener) *eventLoop { + eventLog := log.WithField("userID", user.ID()) + eventLog.Trace("Creating new event loop") + + return &eventLoop{ + cache: cache, + currentEventID: cache.getEventID(user.ID()), + pollCh: make(chan chan struct{}), + isRunning: false, + + log: eventLog, + + store: store, + apiClient: api, + user: user, + events: events, + } +} + +func (loop *eventLoop) IsRunning() bool { + return loop.isRunning +} + +func (loop *eventLoop) setFirstEventID() (err error) { + loop.log.Trace("Setting first event ID") + + event, err := loop.apiClient.GetEvent("") + if err != nil { + loop.log.WithError(err).Error("Could not get latest event ID") + return + } + + loop.currentEventID = event.EventID + + if err = loop.cache.setEventID(loop.user.ID(), loop.currentEventID); err != nil { + loop.log.WithError(err).Error("Could not set latest event ID in user cache") + return + } + + return +} + +// pollNow starts polling events right away and waits till the events are +// processed so we are sure updates are propagated to the database. +func (loop *eventLoop) pollNow() { + eventProcessedCh := make(chan struct{}) + loop.pollCh <- eventProcessedCh + <-eventProcessedCh + close(eventProcessedCh) +} + +func (loop *eventLoop) stop() { + if loop.isRunning { + loop.isRunning = false + close(loop.stopCh) + + select { + case <-loop.notifyStopCh: + loop.log.Info("Event loop was stopped") + case <-time.After(1 * time.Second): + loop.log.Warn("Timed out waiting for event loop to stop") + } + } +} + +func (loop *eventLoop) start() { // nolint[funlen] + if loop.isRunning { + return + } + defer func() { + loop.isRunning = false + }() + loop.stopCh = make(chan struct{}) + loop.notifyStopCh = make(chan struct{}) + loop.isRunning = true + + events := make(chan *pmapi.Event) + defer close(events) + + loop.log.WithField("lastEventID", loop.currentEventID).Info("Subscribed to events") + defer func() { + loop.log.WithField("lastEventID", loop.currentEventID).Info("Subscription stopped") + }() + + t := time.NewTicker(pollInterval) + defer t.Stop() + + loop.hasInternet = true + + go loop.pollNow() + + for { + var eventProcessedCh chan struct{} + select { + case <-loop.stopCh: + close(loop.notifyStopCh) + return + case eventProcessedCh = <-loop.pollCh: + case <-t.C: + } + + // Before we fetch the first event, check whether this is the first time we've + // started the event loop, and if so, trigger a full sync. + // In case internet connection was not available during start, it will be + // handled anyway when the connection is back here. + if loop.isBeforeFirstStart() { + if eventErr := loop.setFirstEventID(); eventErr != nil { + loop.log.WithError(eventErr).Warn("Could not set initial event ID") + } + } + + // If the sync is not finished then a new sync is triggered. + if !loop.store.isSyncFinished() { + loop.store.triggerSync() + } + + more, err := loop.processNextEvent() + if eventProcessedCh != nil { + eventProcessedCh <- struct{}{} + } + if err != nil { + loop.log.WithError(err).Error("Cannot process event, stopping event loop") + // When event loop stops, the only way to start it again is by login. + // It should stop only when user is logged out but even if there is other + // serious error, logout is intended action. + if errLogout := loop.user.Logout(); errLogout != nil { + loop.log. + WithError(errLogout). + Error("Failed to logout user after loop finished with error") + } + return + } + + if more { + go loop.pollNow() + } + } +} + +// isBeforeFirstStart returns whether the initial event ID was already set or not. +func (loop *eventLoop) isBeforeFirstStart() bool { + return loop.currentEventID == "" +} + +// processNextEvent saves only successfully processed `eventID` into cache +// (disk). It will filter out in defer all errors except invalid token error. +// Invalid error will be returned and stop the event loop. +func (loop *eventLoop) processNextEvent() (more bool, err error) { // nolint[funlen] + l := loop.log.WithField("currentEventID", loop.currentEventID) + + // We only want to consider invalid tokens as real errors because all other errors might fix themselves eventually + // (e.g. no internet, ulimit reached etc.) + defer func() { + if errors.Cause(err) == pmapi.ErrAPINotReachable { + l.Warn("Internet unavailable") + loop.events.Emit(bridgeEvents.InternetOffEvent, "") + loop.hasInternet = false + err = nil + } + + if err != nil && isFdCloseToULimit() { + l.Warn("Ulimit reached") + loop.events.Emit(bridgeEvents.RestartBridgeEvent, "") + err = nil + } + + if errors.Cause(err) == pmapi.ErrUpgradeApplication { + l.Warn("Need to upgrade application") + loop.events.Emit(bridgeEvents.UpgradeApplicationEvent, "") + err = nil + } + + _, errUnauthorized := errors.Cause(err).(*pmapi.ErrUnauthorized) + + // All errors except Invalid Token (which is not possible to recover from) are ignored. + if err != nil && !errUnauthorized && errors.Cause(err) != pmapi.ErrInvalidToken { + l.WithError(err).Trace("Error skipped") + err = nil + } + }() + + l.Trace("Polling next event") + var event *pmapi.Event + if event, err = loop.apiClient.GetEvent(loop.currentEventID); err != nil { + return false, errors.Wrap(err, "failed to get event") + } + + l = l.WithField("newEventID", event.EventID) + + if !loop.hasInternet { + loop.events.Emit(bridgeEvents.InternetOnEvent, "") + loop.hasInternet = true + } + + if err = loop.processEvent(event); err != nil { + return false, errors.Wrap(err, "failed to process event") + } + + if loop.currentEventID != event.EventID { + // In case new event ID cannot be saved to cache, we update it in event loop + // anyway and continue processing new events to prevent the loop from repeatedly + // processing the same event. + // This allows the event loop to continue to function (unless the cache was broken + // and bridge stopped, in which case it will start from the old event ID anyway). + loop.currentEventID = event.EventID + if err = loop.cache.setEventID(loop.user.ID(), event.EventID); err != nil { + return false, errors.Wrap(err, "failed to save event ID to cache") + } + } + + return event.More == 1, err +} + +func (loop *eventLoop) processEvent(event *pmapi.Event) (err error) { + eventLog := loop.log.WithField("event", event.EventID) + eventLog.Debug("Processing event") + + if (event.Refresh & pmapi.EventRefreshMail) != 0 { + eventLog.Info("Processing refresh event") + loop.store.triggerSync() + + return + } + + if len(event.Addresses) != 0 { + if err = loop.processAddresses(eventLog, event.Addresses); err != nil { + return errors.Wrap(err, "failed to process address events") + } + } + + if len(event.Labels) != 0 { + if err = loop.processLabels(eventLog, event.Labels); err != nil { + return errors.Wrap(err, "failed to process label events") + } + } + + if len(event.Messages) != 0 { + if err = loop.processMessages(eventLog, event.Messages); err != nil { + return errors.Wrap(err, "failed to process message events") + } + } + + // One would expect that every event would contain MessageCount as part of + // the event.Messages, but this is apparently not the case. + // MessageCounts are served on an irregular basis, so we should update and + // compare the counts only when we receive them. + if len(event.MessageCounts) != 0 { + if err = loop.processMessageCounts(eventLog, event.MessageCounts); err != nil { + return errors.Wrap(err, "failed to process message count events") + } + } + + if len(event.Notices) != 0 { + loop.processNotices(eventLog, event.Notices) + } + + return err +} + +func (loop *eventLoop) processAddresses(log *logrus.Entry, addressEvents []*pmapi.EventAddress) (err error) { + log.Debug("Processing address change event") + + // Get old addresses for comparisons before updating user. + oldList := loop.apiClient.Addresses() + + if err = loop.user.UpdateUser(); err != nil { + if logoutErr := loop.user.Logout(); logoutErr != nil { + log.WithError(logoutErr).Error("Failed to logout user after failed update") + } + return errors.Wrap(err, "failed to update user") + } + + for _, addressEvent := range addressEvents { + switch addressEvent.Action { + case pmapi.EventCreate: + log.WithField("email", addressEvent.Address.Email).Debug("Address was created") + loop.events.Emit(bridgeEvents.AddressChangedEvent, loop.user.GetPrimaryAddress()) + + case pmapi.EventUpdate: + oldAddress := oldList.ByID(addressEvent.ID) + if oldAddress == nil { + log.Warning("Event refers to an address that isn't present") + continue + } + + email := oldAddress.Email + log.WithField("email", email).Debug("Address was updated") + if addressEvent.Address.Receive != oldAddress.Receive { + loop.events.Emit(bridgeEvents.AddressChangedLogoutEvent, email) + } + + case pmapi.EventDelete: + oldAddress := oldList.ByID(addressEvent.ID) + if oldAddress == nil { + log.Warning("Event refers to an address that isn't present") + continue + } + + email := oldAddress.Email + log.WithField("email", email).Debug("Address was deleted") + loop.user.CloseConnection(email) + loop.events.Emit(bridgeEvents.AddressChangedLogoutEvent, email) + } + } + + if err = loop.store.createOrUpdateAddressInfo(loop.apiClient.Addresses()); err != nil { + return errors.Wrap(err, "failed to update address IDs in store") + } + + if err = loop.store.createOrDeleteAddressesEvent(); err != nil { + return errors.Wrap(err, "failed to create/delete store addresses") + } + + return nil +} + +func (loop *eventLoop) processLabels(eventLog *logrus.Entry, labels []*pmapi.EventLabel) error { + eventLog.Debug("Processing label change event") + + for _, eventLabel := range labels { + label := eventLabel.Label + switch eventLabel.Action { + case pmapi.EventCreate, pmapi.EventUpdate: + if err := loop.store.createOrUpdateMailboxEvent(label); err != nil { + return errors.Wrap(err, "failed to create or update label") + } + case pmapi.EventDelete: + if err := loop.store.deleteMailboxEvent(eventLabel.ID); err != nil { + return errors.Wrap(err, "failed to delete label") + } + } + } + + return nil +} + +func (loop *eventLoop) processMessages(eventLog *logrus.Entry, messages []*pmapi.EventMessage) (err error) { + eventLog.Debug("Processing message change event") + + for _, message := range messages { + msgLog := eventLog.WithField("msgID", message.ID) + + switch message.Action { + case pmapi.EventCreate: + msgLog.Debug("Processing EventCreate for message") + + if message.Created == nil { + msgLog.Error("Got EventCreate with nil message") + break + } + + if err = loop.store.createOrUpdateMessageEvent(message.Created); err != nil { + return errors.Wrap(err, "failed to put message into DB") + } + + case pmapi.EventUpdate, pmapi.EventUpdateFlags: + msgLog.Debug("Processing EventUpdate(Flags) for message") + + if message.Updated == nil { + msgLog.Errorf("Got EventUpdate(Flags) with nil message") + break + } + + var msg *pmapi.Message + msg, err = loop.store.getMessageFromDB(message.ID) + if err == ErrNoSuchAPIID { + msgLog.WithError(err).Warning("Cannot get message from DB for updating. Trying fetch...") + msg, err = loop.store.fetchMessage(message.ID) + // If message does not exist anywhere, update event is probably old and off topic - skip it. + if err == ErrNoSuchAPIID { + msgLog.Warn("Skipping message update, because message does not exist nor in local DB or on API") + continue + } + } + if err != nil { + return errors.Wrap(err, "failed to get message from DB for updating") + } + + updateMessage(msgLog, msg, message.Updated) + + if err = loop.store.createOrUpdateMessageEvent(msg); err != nil { + return errors.Wrap(err, "failed to update message in DB") + } + + case pmapi.EventDelete: + msgLog.Debug("Processing EventDelete for message") + + if err = loop.store.deleteMessageEvent(message.ID); err != nil { + return errors.Wrap(err, "failed to delete message from DB") + } + } + } + + return err +} + +func updateMessage(msgLog *logrus.Entry, message *pmapi.Message, updates *pmapi.EventMessageUpdated) { //nolint[funlen] + msgLog.Debug("Updating message") + + message.Time = updates.Time + + if updates.Subject != nil { + msgLog.WithField("subject", *updates.Subject).Trace("Updating message value") + message.Subject = *updates.Subject + } + + if updates.Sender != nil { + msgLog.WithField("sender", *updates.Sender).Trace("Updating message value") + message.Sender = updates.Sender + } + + if updates.ToList != nil { + msgLog.WithField("toList", *updates.ToList).Trace("Updating message value") + message.ToList = *updates.ToList + } + + if updates.CCList != nil { + msgLog.WithField("ccList", *updates.CCList).Trace("Updating message value") + message.CCList = *updates.CCList + } + + if updates.BCCList != nil { + msgLog.WithField("bccList", *updates.BCCList).Trace("Updating message value") + message.BCCList = *updates.BCCList + } + + if updates.Unread != nil { + msgLog.WithField("unread", *updates.Unread).Trace("Updating message value") + message.Unread = *updates.Unread + } + + if updates.Flags != nil { + msgLog.WithField("flags", *updates.Flags).Trace("Updating message value") + message.Flags = *updates.Flags + } + + if updates.LabelIDs != nil { + msgLog.WithField("labelIDs", updates.LabelIDs).Trace("Updating message value") + message.LabelIDs = updates.LabelIDs + } else { + for _, added := range updates.LabelIDsAdded { + hasLabel := false + for _, l := range message.LabelIDs { + if added == l { + hasLabel = true + break + } + } + if !hasLabel { + msgLog.WithField("added", added).Trace("Adding label to message") + message.LabelIDs = append(message.LabelIDs, added) + } + } + + labels := []string{} + for _, l := range message.LabelIDs { + removeLabel := false + for _, removed := range updates.LabelIDsRemoved { + if removed == l { + removeLabel = true + break + } + } + if removeLabel { + msgLog.WithField("label", l).Trace("Removing label from message") + } else { + labels = append(labels, l) + } + } + + message.LabelIDs = labels + } +} + +func (loop *eventLoop) processMessageCounts(l *logrus.Entry, messageCounts []*pmapi.MessagesCount) error { + l.WithField("apiCounts", messageCounts).Debug("Processing message count change event") + + isSynced, err := loop.store.isSynced(messageCounts) + if err != nil { + return err + } + if !isSynced { + loop.store.triggerSync() + } + + return nil +} + +func (loop *eventLoop) processNotices(l *logrus.Entry, notices []string) { + l.Debug("Processing notice change event") + + for _, notice := range notices { + l.Infof("Notice: %q", notice) + for _, address := range loop.user.GetStoreAddresses() { + loop.store.imapNotice(address, notice) + } + } +} diff --git a/internal/store/event_loop_test.go b/internal/store/event_loop_test.go new file mode 100644 index 00000000..7bb67053 --- /dev/null +++ b/internal/store/event_loop_test.go @@ -0,0 +1,153 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "net/mail" + "testing" + "time" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" +) + +func TestEventLoopProcessMoreEvents(t *testing.T) { + m, clear := initMocks(t) + defer clear() + + // Event expectations need to be defined before calling `newStoreNoEvents` + // to force to use these for this particular test. + // Also, event loop calls ListMessages again and we need to place it after + // calling `newStoreNoEvents` to not break expectations for the first sync. + gomock.InOrder( + // Doesn't matter which IDs are used. + // This test is trying to see whether event loop will immediately process + // next event if there is `More` of them. + m.api.EXPECT().GetEvent("latestEventID").Return(&pmapi.Event{ + EventID: "event50", + More: 1, + }, nil), + m.api.EXPECT().GetEvent("event50").Return(&pmapi.Event{ + EventID: "event70", + More: 0, + }, nil), + m.api.EXPECT().GetEvent("event70").Return(&pmapi.Event{ + EventID: "event71", + More: 0, + }, nil), + ) + m.newStoreNoEvents(true) + m.api.EXPECT().ListMessages(gomock.Any()).Return([]*pmapi.Message{}, 0, nil).AnyTimes() + + // Event loop runs in goroutine and will be stopped by deferred mock clearing. + go m.store.eventLoop.start() + + // More events are processed right away. + require.Eventually(t, func() bool { + return m.store.eventLoop.currentEventID == "event70" + }, time.Second, 10*time.Millisecond) + + // For normal event we need to wait to next polling. + time.Sleep(pollInterval) + require.Eventually(t, func() bool { + return m.store.eventLoop.currentEventID == "event71" + }, time.Second, 10*time.Millisecond) +} + +func TestEventLoopUpdateMessageFromLoop(t *testing.T) { + m, clear := initMocks(t) + defer clear() + + subject := "old subject" + newSubject := "new subject" + + // First sync will add message with old subject to database. + m.api.EXPECT().GetMessage("msg1").Return(&pmapi.Message{ + ID: "msg1", + Subject: subject, + }, nil) + // Event will update the subject. + m.api.EXPECT().GetEvent("latestEventID").Return(&pmapi.Event{ + EventID: "event1", + Messages: []*pmapi.EventMessage{{ + EventItem: pmapi.EventItem{ + ID: "msg1", + Action: pmapi.EventUpdate, + }, + Updated: &pmapi.EventMessageUpdated{ + ID: "msg1", + Subject: &newSubject, + }, + }}, + }, nil) + + m.newStoreNoEvents(true) + + // Event loop runs in goroutine and will be stopped by deferred mock clearing. + go m.store.eventLoop.start() + + require.Eventually(t, func() bool { + msg, err := m.store.getMessageFromDB("msg1") + return err == nil && msg.Subject == newSubject + }, time.Second, 10*time.Millisecond) +} + +func TestEventLoopUpdateMessage(t *testing.T) { + address1 := &mail.Address{Address: "user1@example.com"} + address2 := &mail.Address{Address: "user2@example.com"} + msg := &pmapi.Message{ + ID: "msg1", + Subject: "old", + Unread: 0, + Flags: 10, + Sender: address1, + ToList: []*mail.Address{address2}, + CCList: []*mail.Address{address1}, + BCCList: []*mail.Address{}, + Time: 20, + LabelIDs: []string{"old"}, + } + newMsg := &pmapi.Message{ + ID: "msg1", + Subject: "new", + Unread: 1, + Flags: 11, + Sender: address2, + ToList: []*mail.Address{address1}, + CCList: []*mail.Address{address2}, + BCCList: []*mail.Address{address1}, + Time: 21, + LabelIDs: []string{"new"}, + } + + updateMessage(log, msg, &pmapi.EventMessageUpdated{ + ID: "msg1", + Subject: &newMsg.Subject, + Unread: &newMsg.Unread, + Flags: &newMsg.Flags, + Sender: newMsg.Sender, + ToList: &newMsg.ToList, + CCList: &newMsg.CCList, + BCCList: &newMsg.BCCList, + Time: newMsg.Time, + LabelIDs: newMsg.LabelIDs, + }) + + require.Equal(t, newMsg, msg) +} diff --git a/internal/store/mailbox.go b/internal/store/mailbox.go new file mode 100644 index 00000000..c6232765 --- /dev/null +++ b/internal/store/mailbox.go @@ -0,0 +1,265 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/sirupsen/logrus" + bolt "go.etcd.io/bbolt" +) + +// Mailbox is mailbox for specific address and mailbox. +type Mailbox struct { + store *Store + storeAddress *Address + + labelID string + labelPrefix string + labelName string + color string + + log *logrus.Entry +} + +func newMailbox(storeAddress *Address, labelID, labelPrefix, labelName, color string) (mb *Mailbox, err error) { + l := log. + WithField("addrID", storeAddress.addressID). + WithField("lblID", labelID) + mb = &Mailbox{ + store: storeAddress.store, + storeAddress: storeAddress, + labelID: labelID, + labelPrefix: labelPrefix, + labelName: labelPrefix + labelName, + color: color, + log: l, + } + + if err = mb.store.db.Update(func(tx *bolt.Tx) error { + return initMailboxBucket(tx, mb.getBucketName()) + }); err != nil { + l.WithError(err).Error("Could not initialise mailbox buckets") + } + + syncDraftsIfNecssary(mb) + + return +} + +func syncDraftsIfNecssary(mb *Mailbox) { //nolint[funlen] + // We didn't support drafts before v1.2.6 and therefore if we now created + // Drafts mailbox we need to check whether counts match (drafts are synced). + // If not, sync them from local metadata without need to do full resync, + // Can be removed with 1.2.7 or later. + if mb.labelID != pmapi.DraftLabel { + return + } + + // If the drafts mailbox total is non-zero, it means it has already been used + // and there is no need to continue. Otherwise, we may need to do an initial sync. + total, _, err := mb.GetCounts() + if err != nil || total != 0 { + return + } + + counts, err := mb.store.getOnAPICounts() + if err != nil { + return + } + + foundCounts := false + doSync := false + for _, count := range counts { + if count.LabelID != pmapi.DraftLabel { + continue + } + foundCounts = true + log.WithField("total", total).WithField("total-api", count.TotalOnAPI).Debug("Drafts mailbox created: checking need for sync") + if count.TotalOnAPI == total { + continue + } + doSync = true + break + } + + if !foundCounts { + log.Debug("Drafts mailbox created: missing counts, refreshing") + _ = mb.store.updateCountsFromServer() + } + + if !foundCounts || doSync { + err := mb.store.db.Update(func(tx *bolt.Tx) error { + return tx.Bucket(metadataBucket).ForEach(func(k, v []byte) error { + msg := &pmapi.Message{} + if err := json.Unmarshal(v, msg); err != nil { + return err + } + for _, msgLabelID := range msg.LabelIDs { + if msgLabelID == pmapi.DraftLabel { + log.WithField("id", msg.ID).Debug("Drafts mailbox created: syncing draft locally") + _ = mb.txCreateOrUpdateMessages(tx, []*pmapi.Message{msg}) + break + } + } + return nil + }) + }) + log.WithError(err).Info("Drafts mailbox created: synced localy") + } +} + +func initMailboxBucket(tx *bolt.Tx, bucketName []byte) error { + bucket, err := tx.Bucket(mailboxesBucket).CreateBucketIfNotExists(bucketName) + if err != nil { + return err + } + + if _, err := bucket.CreateBucketIfNotExists(imapIDsBucket); err != nil { + return err + } + if _, err := bucket.CreateBucketIfNotExists(apiIDsBucket); err != nil { + return err + } + + return nil +} + +// LabelID returns ID of mailbox. +func (storeMailbox *Mailbox) LabelID() string { + return storeMailbox.labelID +} + +// Name returns the name of mailbox. +func (storeMailbox *Mailbox) Name() string { + return storeMailbox.labelName +} + +// Color returns the color of mailbox. +func (storeMailbox *Mailbox) Color() string { + return storeMailbox.color +} + +// UIDValidity returns the current value of structure version. +func (storeMailbox *Mailbox) UIDValidity() uint32 { + return storeMailbox.store.getMailboxesVersion() +} + +// IsFolder returns whether the mailbox is a folder (has "Folders/" prefix). +func (storeMailbox *Mailbox) IsFolder() bool { + return storeMailbox.labelPrefix == UserFoldersPrefix +} + +// IsLabel returns whether the mailbox is a label (has "Labels/" prefix). +func (storeMailbox *Mailbox) IsLabel() bool { + return storeMailbox.labelPrefix == UserLabelsPrefix +} + +// IsSystem returns whether the mailbox is one of the specific system mailboxes (has no prefix). +func (storeMailbox *Mailbox) IsSystem() bool { + return storeMailbox.labelPrefix == "" +} + +// Rename updates the mailbox by calling an API. +// Change has to be propagated to all the same mailboxes in all addresses. +// The propagation is processed by the event loop. +func (storeMailbox *Mailbox) Rename(newName string) error { + if storeMailbox.IsSystem() { + return fmt.Errorf("cannot rename system mailboxes") + } + + if storeMailbox.IsFolder() { + if !strings.HasPrefix(newName, UserFoldersPrefix) { + return fmt.Errorf("cannot rename folder to non-folder") + } + + newName = strings.TrimPrefix(newName, UserFoldersPrefix) + } + + if storeMailbox.IsLabel() { + if !strings.HasPrefix(newName, UserLabelsPrefix) { + return fmt.Errorf("cannot rename label to non-label") + } + + newName = strings.TrimPrefix(newName, UserLabelsPrefix) + } + + return storeMailbox.storeAddress.updateMailbox(storeMailbox.labelID, newName, storeMailbox.color) +} + +// Delete deletes the mailbox by calling an API. +// Deletion has to be propagated to all the same mailboxes in all addresses. +// The propagation is processed by the event loop. +func (storeMailbox *Mailbox) Delete() error { + return storeMailbox.storeAddress.deleteMailbox(storeMailbox.labelID) +} + +// GetDelimiter returns the path separator. +func (storeMailbox *Mailbox) GetDelimiter() string { + return PathDelimiter +} + +// deleteMailboxEvent deletes the mailbox bucket. +// This is called from the event loop. +func (storeMailbox *Mailbox) deleteMailboxEvent() error { + return storeMailbox.db().Update(func(tx *bolt.Tx) error { + return tx.Bucket(mailboxesBucket).DeleteBucket(storeMailbox.getBucketName()) + }) +} + +// txGetIMAPIDsBucket returns the bucket mapping IMAP ID to API ID. +func (storeMailbox *Mailbox) txGetIMAPIDsBucket(tx *bolt.Tx) *bolt.Bucket { + return storeMailbox.txGetBucket(tx).Bucket(imapIDsBucket) +} + +// txGetAPIIDsBucket returns the bucket mapping API ID to IMAP ID. +func (storeMailbox *Mailbox) txGetAPIIDsBucket(tx *bolt.Tx) *bolt.Bucket { + return storeMailbox.txGetBucket(tx).Bucket(apiIDsBucket) +} + +// txGetBucket returns the bucket of mailbox containing mapping buckets. +func (storeMailbox *Mailbox) txGetBucket(tx *bolt.Tx) *bolt.Bucket { + return tx.Bucket(mailboxesBucket).Bucket(storeMailbox.getBucketName()) +} + +func getMailboxBucketName(addressID, labelID string) []byte { + return []byte(addressID + "-" + labelID) +} + +// getBucketName returns the name of mailbox bucket. +func (storeMailbox *Mailbox) getBucketName() []byte { + return getMailboxBucketName(storeMailbox.storeAddress.addressID, storeMailbox.labelID) +} + +// pollNow is a proxy for the store's eventloop's `pollNow()`. +func (storeMailbox *Mailbox) pollNow() { + storeMailbox.store.eventLoop.pollNow() +} + +// api is a proxy for the store's `PMAPIProvider`. +func (storeMailbox *Mailbox) api() PMAPIProvider { + return storeMailbox.store.api +} + +// update is a proxy for the store's db's `Update`. +func (storeMailbox *Mailbox) db() *bolt.DB { + return storeMailbox.store.db +} diff --git a/internal/store/mailbox_counts.go b/internal/store/mailbox_counts.go new file mode 100644 index 00000000..03e4d046 --- /dev/null +++ b/internal/store/mailbox_counts.go @@ -0,0 +1,257 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "bytes" + "encoding/json" + "sort" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/pkg/errors" + bolt "go.etcd.io/bbolt" +) + +// GetCounts returns numbers of total and unread messages in this mailbox bucket. +func (storeMailbox *Mailbox) GetCounts() (total, unread uint, err error) { + err = storeMailbox.db().View(func(tx *bolt.Tx) error { + total, unread, err = storeMailbox.txGetCounts(tx) + return err + }) + return +} + +func (storeMailbox *Mailbox) txGetCounts(tx *bolt.Tx) (total, unread uint, err error) { + // For total it would be enough to use `bolt.Bucket.Stats().KeyN` but + // we also need to retrieve the count of unread emails therefore we are + // looping all messages in this mailbox by `bolt.Cursor` + metaBucket := tx.Bucket(metadataBucket) + b := storeMailbox.txGetIMAPIDsBucket(tx) + c := b.Cursor() + imapID, apiID := c.First() + for ; imapID != nil; imapID, apiID = c.Next() { + total++ + rawMsg := metaBucket.Get(apiID) + if rawMsg == nil { + return 0, 0, ErrNoSuchAPIID + } + // Do not unmarshal whole JSON to speed up the looping. + // Instead, we assume it will contain JSON int field `Unread` + // where `1` means true (i.e. message is unread) + if bytes.Contains(rawMsg, []byte(`"Unread":1`)) { + unread++ + } + } + return total, unread, err +} + +type mailboxCounts struct { + LabelID string + LabelName string + Color string + Order int + IsFolder bool + TotalOnAPI uint + UnreadOnAPI uint +} + +func txGetCountsFromBucketOrNew(bkt *bolt.Bucket, labelID string) (*mailboxCounts, error) { + mc := &mailboxCounts{} + if mcJSON := bkt.Get([]byte(labelID)); mcJSON != nil { + if err := json.Unmarshal(mcJSON, mc); err != nil { + return nil, err + } + } + mc.LabelID = labelID // if it was empty before we need to set labelID + + return mc, nil +} + +func (mc *mailboxCounts) txWriteToBucket(bucket *bolt.Bucket) error { + mcJSON, err := json.Marshal(mc) + if err != nil { + return err + } + return bucket.Put([]byte(mc.LabelID), mcJSON) +} + +func getSystemFolders() []*mailboxCounts { + return []*mailboxCounts{ + {pmapi.InboxLabel, "INBOX", "#000", -1000, true, 0, 0}, + {pmapi.SentLabel, "Sent", "#000", -9, true, 0, 0}, + {pmapi.ArchiveLabel, "Archive", "#000", -8, true, 0, 0}, + {pmapi.SpamLabel, "Spam", "#000", -7, true, 0, 0}, + {pmapi.TrashLabel, "Trash", "#000", -6, true, 0, 0}, + {pmapi.AllMailLabel, "All Mail", "#000", -5, true, 0, 0}, + {pmapi.DraftLabel, "Drafts", "#000", -4, true, 0, 0}, + } +} + +// skipThisLabel decides to skip labelIDs that *are* pmapi system labels but *aren't* local system labels +// (i.e. if it's in `pmapi.SystemLabels` but not in `getSystemFolders` then we skip it, otherwise we don't). +func skipThisLabel(labelID string) bool { + switch labelID { + case pmapi.StarredLabel, pmapi.AllSentLabel, pmapi.AllDraftsLabel: + return true + } + return false +} + +func sortByOrder(labels []*pmapi.Label) { + sort.Slice(labels, func(i, j int) bool { + return labels[i].Order < labels[j].Order + }) +} + +func (mc *mailboxCounts) getPMLabel() *pmapi.Label { + return &pmapi.Label{ + ID: mc.LabelID, + Name: mc.LabelName, + Color: mc.Color, + Order: mc.Order, + Type: pmapi.LabelTypeMailbox, + Exclusive: mc.isExclusive(), + } +} + +func (mc *mailboxCounts) isExclusive() int { + if mc.IsFolder { + return 1 + } + return 0 +} + +// createOrUpdateMailboxCountsBuckets will not change the on-API-counts. +func (store *Store) createOrUpdateMailboxCountsBuckets(labels []*pmapi.Label) error { + // Don't forget about system folders. + // It should set label id, name, color, isFolder, total, unread. + tx := func(tx *bolt.Tx) error { + countsBkt := tx.Bucket(countsBucket) + for _, label := range labels { + // Skipping is probably not necessary. + if skipThisLabel(label.ID) { + continue + } + + // Get current data. + mailbox, err := txGetCountsFromBucketOrNew(countsBkt, label.ID) + if err != nil { + return err + } + + // Update mailbox info, but dont change on-API-counts. + mailbox.LabelName = label.Name + mailbox.Color = label.Color + mailbox.Order = label.Order + mailbox.IsFolder = label.Exclusive == 1 + + // Write. + if err = mailbox.txWriteToBucket(countsBkt); err != nil { + return err + } + } + return nil + } + + return store.db.Update(tx) +} + +func (store *Store) getLabelsFromLocalStorage() ([]*pmapi.Label, error) { + countsOnAPI, err := store.getOnAPICounts() + if err != nil { + return nil, err + } + labels := []*pmapi.Label{} + for _, counts := range countsOnAPI { + labels = append(labels, counts.getPMLabel()) + } + sortByOrder(labels) + + return labels, nil +} + +func (store *Store) getOnAPICounts() ([]*mailboxCounts, error) { + counts := []*mailboxCounts{} + tx := func(tx *bolt.Tx) error { + c := tx.Bucket(countsBucket).Cursor() + for k, countsB := c.First(); k != nil; k, countsB = c.Next() { + l := store.log.WithField("key", string(k)) + if countsB == nil { + err := errors.New("empty counts in DB") + l.WithError(err).Error("While getting local labels") + return err + } + + mbCounts := &mailboxCounts{} + if err := json.Unmarshal(countsB, mbCounts); err != nil { + l.WithError(err).Error("While unmarshaling local labels") + return err + } + + counts = append(counts, mbCounts) + } + return nil + } + err := store.db.View(tx) + + return counts, err +} + +// createOrUpdateOnAPICounts will change only on-API-counts. +func (store *Store) createOrUpdateOnAPICounts(mailboxCountsOnAPI []*pmapi.MessagesCount) error { + store.log.WithField("apiCounts", mailboxCountsOnAPI).Debug("Updating API counts") + + tx := func(tx *bolt.Tx) error { + countsBkt := tx.Bucket(countsBucket) + for _, countsOnAPI := range mailboxCountsOnAPI { + if skipThisLabel(countsOnAPI.LabelID) { + continue + } + + // Get current data. + counts, err := txGetCountsFromBucketOrNew(countsBkt, countsOnAPI.LabelID) + if err != nil { + return err + } + + // Update only counts. + counts.TotalOnAPI = uint(countsOnAPI.Total) + counts.UnreadOnAPI = uint(countsOnAPI.Unread) + + if err = counts.txWriteToBucket(countsBkt); err != nil { + return err + } + } + + return nil + } + + return store.db.Update(tx) +} + +func (store *Store) removeMailboxCount(labelID string) error { + err := store.db.Update(func(tx *bolt.Tx) error { + return tx.Bucket(countsBucket).Delete([]byte(labelID)) + }) + if err != nil { + store.log.WithError(err). + WithField("labelID", labelID). + Warning("Cannot remove counts") + } + return err +} diff --git a/internal/store/mailbox_counts_test.go b/internal/store/mailbox_counts_test.go new file mode 100644 index 00000000..27686790 --- /dev/null +++ b/internal/store/mailbox_counts_test.go @@ -0,0 +1,126 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "testing" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + a "github.com/stretchr/testify/assert" +) + +func newLabel(order int, id, name string) *pmapi.Label { + return &pmapi.Label{ + ID: id, + Name: name, + Order: order, + } +} + +func TestSortByOrder(t *testing.T) { + want := []*pmapi.Label{ + newLabel(-1000, pmapi.InboxLabel, "INBOX"), + newLabel(-5, pmapi.SentLabel, "Sent"), + newLabel(-4, pmapi.ArchiveLabel, "Archive"), + newLabel(-3, pmapi.SpamLabel, "Spam"), + newLabel(-2, pmapi.TrashLabel, "Trash"), + newLabel(-1, pmapi.AllMailLabel, "All Mail"), + newLabel(100, "labelID1", "custom_label"), + newLabel(1000, "folderID1", "custom_folder"), + } + labels := []*pmapi.Label{ + want[6], + want[4], + want[3], + want[7], + want[5], + want[0], + want[2], + want[1], + } + + sortByOrder(labels) + a.Equal(t, want, labels) +} + +func TestMailboxNames(t *testing.T) { + want := map[string]string{ + pmapi.InboxLabel: "INBOX", + pmapi.SentLabel: "Sent", + pmapi.ArchiveLabel: "Archive", + pmapi.SpamLabel: "Spam", + pmapi.TrashLabel: "Trash", + pmapi.AllMailLabel: "All Mail", + pmapi.DraftLabel: "Drafts", + "labelID1": "Labels/Label1", + "folderID1": "Folders/Folder1", + } + + foldersAndLabels := []*pmapi.Label{ + newLabel(100, "labelID1", "Label1"), + newLabel(1000, "folderID1", "Folder1"), + } + foldersAndLabels[1].Exclusive = 1 + + for _, counts := range getSystemFolders() { + foldersAndLabels = append(foldersAndLabels, counts.getPMLabel()) + } + + got := map[string]string{} + for _, m := range foldersAndLabels { + got[m.ID] = getLabelPrefix(m) + m.Name + } + a.Equal(t, want, got) +} + +func TestAddSystemLabels(t *testing.T) {} + +func checkCounts(t testing.TB, wantCounts []*pmapi.MessagesCount, haveStore *Store) { + nSystemFolders := 7 + haveCounts, err := haveStore.getOnAPICounts() + a.NoError(t, err) + a.Len(t, haveCounts, len(wantCounts)+nSystemFolders) + for iWant, wantCount := range wantCounts { + iHave := iWant + nSystemFolders + haveCount := haveCounts[iHave] + a.Equal(t, wantCount.LabelID, haveCount.LabelID, "iHave:%d\niWant:%d\nHave:%v\nWant:%v", iHave, iWant, haveCount, wantCount) + a.Equal(t, wantCount.Total, int(haveCount.TotalOnAPI), "iHave:%d\niWant:%d\nHave:%v\nWant:%v", iHave, iWant, haveCount, wantCount) + a.Equal(t, wantCount.Unread, int(haveCount.UnreadOnAPI), "iHave:%d\niWant:%d\nHave:%v\nWant:%v", iHave, iWant, haveCount, wantCount) + } +} + +func TestMailboxCountRemove(t *testing.T) { + m, clear := initMocks(t) + defer clear() + m.newStoreNoEvents(true) + + testCounts := []*pmapi.MessagesCount{ + {LabelID: "label1", Total: 100, Unread: 0}, + {LabelID: "label2", Total: 100, Unread: 30}, + {LabelID: "label4", Total: 100, Unread: 100}, + } + a.NoError(t, m.store.createOrUpdateOnAPICounts(testCounts)) + + a.NoError(t, m.store.removeMailboxCount("not existing")) + checkCounts(t, testCounts, m.store) + + var pop *pmapi.MessagesCount + pop, testCounts = testCounts[2], testCounts[0:2] + a.NoError(t, m.store.removeMailboxCount(pop.LabelID)) + checkCounts(t, testCounts, m.store) +} diff --git a/internal/store/mailbox_ids.go b/internal/store/mailbox_ids.go new file mode 100644 index 00000000..4fc170b5 --- /dev/null +++ b/internal/store/mailbox_ids.go @@ -0,0 +1,263 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "bytes" + "math" + "net/mail" + "regexp" + "strings" + + "github.com/ProtonMail/proton-bridge/internal/imap/uidplus" + "github.com/pkg/errors" + bolt "go.etcd.io/bbolt" +) + +// GetAPIIDsFromUIDRange returns API IDs by IMAP UID range. +// +// API IDs are the long base64 strings that the API uses to identify messages. +// UIDs are unique increasing integers that must be unique within a mailbox. +func (storeMailbox *Mailbox) GetAPIIDsFromUIDRange(start, stop uint32) (apiIDs []string, err error) { + err = storeMailbox.db().View(func(tx *bolt.Tx) error { + b := storeMailbox.txGetIMAPIDsBucket(tx) + + if stop == 0 { + // A null stop means no stop. + stop = ^uint32(0) + } + + startb := itob(start) + stopb := itob(stop) + + c := b.Cursor() + for k, v := c.Seek(startb); k != nil && bytes.Compare(k, stopb) <= 0; k, v = c.Next() { + apiIDs = append(apiIDs, string(v)) + } + + return nil + }) + return +} + +// GetAPIIDsFromSequenceRange returns API IDs by IMAP sequence number range. +func (storeMailbox *Mailbox) GetAPIIDsFromSequenceRange(start, stop uint32) (apiIDs []string, err error) { + err = storeMailbox.db().View(func(tx *bolt.Tx) error { + b := storeMailbox.txGetIMAPIDsBucket(tx) + c := b.Cursor() + var i uint32 + for k, v := c.First(); k != nil; k, v = c.Next() { + i++ + if i < start { + continue + } + if stop > 0 && i > stop { + break + } + apiIDs = append(apiIDs, string(v)) + } + return nil + }) + return +} + +// GetLatestAPIID returns the latest message API ID which still exists. +// Info: not the latest IMAP UID which can be already removed. +func (storeMailbox *Mailbox) GetLatestAPIID() (apiID string, err error) { + err = storeMailbox.db().View(func(tx *bolt.Tx) error { + b := storeMailbox.txGetAPIIDsBucket(tx) + c := b.Cursor() + lastAPIID, _ := c.Last() + apiID = string(lastAPIID) + if apiID == "" { + return errors.New("cannot get latest API ID: empty mailbox") + } + return nil + }) + return +} + +// GetNextUID returns the next IMAP UID. +func (storeMailbox *Mailbox) GetNextUID() (uid uint32, err error) { + err = storeMailbox.db().View(func(tx *bolt.Tx) error { + b := storeMailbox.txGetIMAPIDsBucket(tx) + uid, err = storeMailbox.txGetNextUID(b, false) + return err + }) + return +} + +func (storeMailbox *Mailbox) txGetNextUID(imapIDBucket *bolt.Bucket, write bool) (uint32, error) { + var uid uint64 + var err error + if write { + uid, err = imapIDBucket.NextSequence() + if err != nil { + return 0, err + } + } else { + uid = imapIDBucket.Sequence() + 1 + } + if math.MaxUint32 <= uid { + return 0, errors.New("too large sequence number") + } + return uint32(uid), nil +} + +// getUID returns IMAP UID in this mailbox for message ID. +func (storeMailbox *Mailbox) getUID(apiID string) (uid uint32, err error) { + err = storeMailbox.db().View(func(tx *bolt.Tx) error { + uid, err = storeMailbox.txGetUID(tx, apiID) + return err + }) + return +} + +func (storeMailbox *Mailbox) txGetUID(tx *bolt.Tx, apiID string) (uint32, error) { + b := storeMailbox.txGetAPIIDsBucket(tx) + v := b.Get([]byte(apiID)) + if v == nil { + return 0, ErrNoSuchAPIID + } + return btoi(v), nil +} + +// getSequenceNumber returns IMAP sequence number in the mailbox for the message with the given API ID `apiID`. +func (storeMailbox *Mailbox) getSequenceNumber(apiID string) (seqNum uint32, err error) { + err = storeMailbox.db().View(func(tx *bolt.Tx) error { + b := storeMailbox.txGetIMAPIDsBucket(tx) + uid, err := storeMailbox.txGetUID(tx, apiID) + if err != nil { + return err + } + seqNum, err = storeMailbox.txGetSequenceNumberOfUID(b, itob(uid)) + return err + }) + return +} + +// txGetSequenceNumberOfUID returns the IMAP sequence number of the message +// with the given IMAP UID bytes `uidb`. +// +// NOTE: The `bolt.Cursor.Next()` loops in order of ascending key bytes. The +// IMAP UID bucket is ordered by increasing UID because it's using BigEndian to +// encode uint into byte. Hence the sequence number (IMAP ID) corresponds to +// position of uid key in this order. +func (storeMailbox *Mailbox) txGetSequenceNumberOfUID(bucket *bolt.Bucket, uidb []byte) (uint32, error) { + seqNum := uint32(0) + c := bucket.Cursor() + + // Speed up for the case of last message. This is always true for + // adding new message. It will return number of keys in bucket because + // sequence number starts with 1. + // We cannot use bucket.Stats() for that--it doesn't work in the same + // transaction because stats are updated when transaction is committed. + // But we can at least optimise to not do equal for all keys. + lastKey, _ := c.Last() + isLast := bytes.Equal(lastKey, uidb) + + for k, _ := c.First(); k != nil; k, _ = c.Next() { + seqNum++ // Sequence number starts at 1. + if isLast { + continue + } + if bytes.Equal(k, uidb) { + return seqNum, nil + } + } + + if isLast { + return seqNum, nil + } + + return 0, ErrNoSuchUID +} + +// GetUIDList returns UID list corresponding to messageIDs in a requested order. +func (storeMailbox *Mailbox) GetUIDList(apiIDs []string) *uidplus.OrderedSeq { + seqSet := &uidplus.OrderedSeq{} + _ = storeMailbox.db().View(func(tx *bolt.Tx) error { + b := storeMailbox.txGetAPIIDsBucket(tx) + for _, apiID := range apiIDs { + v := b.Get([]byte(apiID)) + if v == nil { + storeMailbox.log. + WithField("msgID", apiID). + Warn("Cannot find UID") + continue + } + + seqSet.Add(btoi(v)) + } + return nil + }) + return seqSet +} + +// GetUIDByHeader returns UID of message existing in mailbox or zero if no match found. +func (storeMailbox *Mailbox) GetUIDByHeader(header *mail.Header) (foundUID uint32) { + if header == nil { + return uint32(0) + } + + // Message-Id in appended-after-send mail is processed as ExternalID + // in PM message. Message-Id in normal copy/move will be the PM internal ID. + messageID := header.Get("Message-Id") + + // The most often situation is that message is APPENDed after it was sent so the + // Message-ID will be reflected by ExternalID in API message meta-data. + externalID := strings.Trim(messageID, "<> ") // remove '<>' to improve match + matchExternalID := regexp.MustCompile(`"ExternalID":"` + + ` *(\\u003c)? *` + // \u003c is equivalent to `<` + regexp.QuoteMeta(externalID) + + ` *(\\u003e)? *` + // \u0033 is equivalent to `>` + `"`, + ) + + // It is possible that client will try to COPY existing message to Sent + // using APPEND command. In that case the Message-Id from header will + // be internal message ID and we need to check whether it's already there. + matchInternalID := bytes.Split([]byte(externalID), []byte("@"))[0] + + _ = storeMailbox.db().View(func(tx *bolt.Tx) error { + metaBucket := tx.Bucket(metadataBucket) + b := storeMailbox.txGetIMAPIDsBucket(tx) + c := b.Cursor() + imapID, apiID := c.Last() + for ; imapID != nil; imapID, apiID = c.Prev() { + rawMeta := metaBucket.Get(apiID) + if rawMeta == nil { + storeMailbox.log. + WithField("IMAP-UID", imapID). + WithField("API-ID", apiID). + Warn("Cannot find meta-data while searching for externalID") + continue + } + + if !matchExternalID.Match(rawMeta) && !bytes.Equal(apiID, matchInternalID) { + continue + } + + foundUID = btoi(imapID) + return nil + } + return nil + }) + + return foundUID +} diff --git a/internal/store/mailbox_ids_test.go b/internal/store/mailbox_ids_test.go new file mode 100644 index 00000000..567aed77 --- /dev/null +++ b/internal/store/mailbox_ids_test.go @@ -0,0 +1,147 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "net/mail" + "testing" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + a "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type wantID struct { + appID string + uid int +} + +func TestGetSequenceNumberAndGetUID(t *testing.T) { + m, clear := initMocks(t) + defer clear() + + m.newStoreNoEvents(true) + + insertMessage(t, m, "msg1", "Test message 1", addrID1, 0, []string{pmapi.AllMailLabel, pmapi.InboxLabel}) + insertMessage(t, m, "msg2", "Test message 2", addrID1, 0, []string{pmapi.AllMailLabel, pmapi.ArchiveLabel}) + insertMessage(t, m, "msg3", "Test message 3", addrID1, 0, []string{pmapi.AllMailLabel, pmapi.InboxLabel}) + insertMessage(t, m, "msg4", "Test message 4", addrID1, 0, []string{pmapi.AllMailLabel}) + + checkAllMessageIDs(t, m, []string{"msg1", "msg2", "msg3", "msg4"}) + + checkMailboxMessageIDs(t, m, pmapi.InboxLabel, []wantID{{"msg1", 1}, {"msg3", 2}}) + checkMailboxMessageIDs(t, m, pmapi.ArchiveLabel, []wantID{{"msg2", 1}}) + checkMailboxMessageIDs(t, m, pmapi.SpamLabel, []wantID(nil)) + checkMailboxMessageIDs(t, m, pmapi.AllMailLabel, []wantID{{"msg1", 1}, {"msg2", 2}, {"msg3", 3}, {"msg4", 4}}) +} + +// checkMailboxMessageIDs checks that the mailbox contains all API IDs with correct sequence numbers and UIDs. +// wantIDs is map from IMAP UID to API ID. Sequence number is detected automatically by order of the ID in the map. +func checkMailboxMessageIDs(t *testing.T, m *mocksForStore, mailboxLabel string, wantIDs []wantID) { + storeAddress := m.store.addresses[addrID1] + storeMailbox := storeAddress.mailboxes[mailboxLabel] + + ids, err := storeMailbox.GetAPIIDsFromSequenceRange(0, uint32(len(wantIDs))) + require.Nil(t, err) + + idx := 0 + for _, wantID := range wantIDs { + id := ids[idx] + require.Equal(t, wantID.appID, id, "Got IDs: %+v", ids) + + uid, err := storeMailbox.getUID(wantID.appID) + require.Nil(t, err) + a.Equal(t, uint32(wantID.uid), uid) + + seqNum, err := storeMailbox.getSequenceNumber(wantID.appID) + require.Nil(t, err) + a.Equal(t, uint32(idx+1), seqNum) + + idx++ + } +} + +func TestGetUIDByHeader(t *testing.T) { //nolint[funlen] + m, clear := initMocks(t) + defer clear() + + m.newStoreNoEvents(true) + + tstMsg := getTestMessage("msg1", "Without external ID", addrID1, 0, []string{pmapi.AllMailLabel, pmapi.SentLabel}) + require.Nil(t, m.store.createOrUpdateMessageEvent(tstMsg)) + + tstMsg = getTestMessage("msg2", "External ID with spaces", addrID1, 0, []string{pmapi.AllMailLabel, pmapi.SentLabel}) + tstMsg.ExternalID = " externalID-non-pm-com " + require.Nil(t, m.store.createOrUpdateMessageEvent(tstMsg)) + + tstMsg = getTestMessage("msg3", "External ID with <>", addrID1, 0, []string{pmapi.AllMailLabel, pmapi.SentLabel}) + tstMsg.ExternalID = "" + tstMsg.Header = mail.Header{"References": []string{"wrongID", "externalID-non-pm-com", "msg2"}} + require.Nil(t, m.store.createOrUpdateMessageEvent(tstMsg)) + + // Not sure if this is a real-world scenario but we should be able to address this properly. + tstMsg = getTestMessage("msg4", "External ID with <> and spaces and special characters", addrID1, 0, []string{pmapi.AllMailLabel, pmapi.SentLabel}) + tstMsg.ExternalID = " < external.()+*[]ID@another.pm.me > " + require.Nil(t, m.store.createOrUpdateMessageEvent(tstMsg)) + + testDataUIDByHeader := []struct { + header *mail.Header + wantID uint32 + }{ + { + &mail.Header{"Message-Id": []string{"wrongID"}}, + 0, + }, + { + &mail.Header{"Message-Id": []string{"ext"}}, + 0, + }, + { + &mail.Header{"Message-Id": []string{"externalID"}}, + 0, + }, + { + &mail.Header{"Message-Id": []string{"msg1"}}, + 1, + }, + { + &mail.Header{"Message-Id": []string{""}}, + 3, + }, + { + &mail.Header{"Message-Id": []string{""}}, + 2, + }, + { + &mail.Header{"Message-Id": []string{"externalID@pm.me"}}, + 3, + }, + { + &mail.Header{"Message-Id": []string{"external.()+*[]ID@another.pm.me"}}, + 4, + }, + } + + storeAddress := m.store.addresses[addrID1] + storeMailbox := storeAddress.mailboxes[pmapi.SentLabel] + + for _, td := range testDataUIDByHeader { + haveID := storeMailbox.GetUIDByHeader(td.header) + a.Equal(t, td.wantID, haveID, "testing header: %v", td.header) + } +} diff --git a/internal/store/mailbox_message.go b/internal/store/mailbox_message.go new file mode 100644 index 00000000..4718f742 --- /dev/null +++ b/internal/store/mailbox_message.go @@ -0,0 +1,375 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + bolt "go.etcd.io/bbolt" +) + +// GetMessage returns the `pmapi.Message` struct wrapped in `StoreMessage` +// tied to this mailbox. +func (storeMailbox *Mailbox) GetMessage(apiID string) (*Message, error) { + msg, err := storeMailbox.store.getMessageFromDB(apiID) + if err != nil { + return nil, err + } + return newStoreMessage(storeMailbox, msg), nil +} + +// FetchMessage fetches the message with the given `apiID`, stores it in the database, and returns a new store message +// wrapping it. +func (storeMailbox *Mailbox) FetchMessage(apiID string) (*Message, error) { + msg, err := storeMailbox.store.fetchMessage(apiID) + if err != nil { + return nil, err + } + return newStoreMessage(storeMailbox, msg), nil +} + +// ImportMessage imports the message by calling an API. +// It has to be propagated to all mailboxes which is done by the event loop. +func (storeMailbox *Mailbox) ImportMessage(msg *pmapi.Message, body []byte, labelIDs []string) error { + defer storeMailbox.pollNow() + + if storeMailbox.labelID != pmapi.AllMailLabel { + labelIDs = append(labelIDs, storeMailbox.labelID) + } + + importReqs := &pmapi.ImportMsgReq{ + AddressID: msg.AddressID, + Body: body, + Unread: msg.Unread, + Flags: msg.Flags, + Time: msg.Time, + LabelIDs: labelIDs, + } + + res, err := storeMailbox.api().Import([]*pmapi.ImportMsgReq{importReqs}) + if err == nil && len(res) > 0 { + msg.ID = res[0].MessageID + } + return err +} + +// LabelMessages adds the label by calling an API. +// It has to be propagated to all the same messages in all mailboxes. +// The propagation is processed by the event loop. +func (storeMailbox *Mailbox) LabelMessages(apiIDs []string) error { + log.WithFields(logrus.Fields{ + "messages": apiIDs, + "label": storeMailbox.labelID, + "mailbox": storeMailbox.Name, + }).Trace("Labeling messages") + defer storeMailbox.pollNow() + return storeMailbox.api().LabelMessages(apiIDs, storeMailbox.labelID) +} + +// UnlabelMessages removes the label by calling an API. +// It has to be propagated to all the same messages in all mailboxes. +// The propagation is processed by the event loop. +func (storeMailbox *Mailbox) UnlabelMessages(apiIDs []string) error { + log.WithFields(logrus.Fields{ + "messages": apiIDs, + "label": storeMailbox.labelID, + "mailbox": storeMailbox.Name, + }).Trace("Unlabeling messages") + defer storeMailbox.pollNow() + return storeMailbox.api().UnlabelMessages(apiIDs, storeMailbox.labelID) +} + +// MarkMessagesRead marks the message read by calling an API. +// It has to be propagated to metadata mailbox which is done by the event loop. +func (storeMailbox *Mailbox) MarkMessagesRead(apiIDs []string) error { + log.WithFields(logrus.Fields{ + "messages": apiIDs, + "label": storeMailbox.labelID, + "mailbox": storeMailbox.Name, + }).Trace("Marking messages as read") + defer storeMailbox.pollNow() + + // Before deleting a message, TB sets \Seen flag which causes an event update + // and thus a refresh of the message by deleting and creating it again. + // TB does not notice this and happily continues with next command to move + // the message to the Trash but the message does not exist anymore. + // Therefore we do not issue API update if the message is already read. + ids := []string{} + for _, apiID := range apiIDs { + if message, _ := storeMailbox.store.getMessageFromDB(apiID); message == nil || message.Unread == 1 { + ids = append(ids, apiID) + } + } + return storeMailbox.api().MarkMessagesRead(ids) +} + +// MarkMessagesUnread marks the message unread by calling an API. +// It has to be propagated to metadata mailbox which is done by the event loop. +func (storeMailbox *Mailbox) MarkMessagesUnread(apiIDs []string) error { + log.WithFields(logrus.Fields{ + "messages": apiIDs, + "label": storeMailbox.labelID, + "mailbox": storeMailbox.Name, + }).Trace("Marking messages as unread") + defer storeMailbox.pollNow() + return storeMailbox.api().MarkMessagesUnread(apiIDs) +} + +// MarkMessagesStarred adds the Starred label by calling an API. +// It has to be propagated to all the same messages in all mailboxes. +// The propagation is processed by the event loop. +func (storeMailbox *Mailbox) MarkMessagesStarred(apiIDs []string) error { + log.WithFields(logrus.Fields{ + "messages": apiIDs, + "label": storeMailbox.labelID, + "mailbox": storeMailbox.Name, + }).Trace("Marking messages as starred") + defer storeMailbox.pollNow() + return storeMailbox.api().LabelMessages(apiIDs, pmapi.StarredLabel) +} + +// MarkMessagesUnstarred removes the Starred label by calling an API. +// It has to be propagated to all the same messages in all mailboxes. +// The propagation is processed by the event loop. +func (storeMailbox *Mailbox) MarkMessagesUnstarred(apiIDs []string) error { + log.WithFields(logrus.Fields{ + "messages": apiIDs, + "label": storeMailbox.labelID, + "mailbox": storeMailbox.Name, + }).Trace("Marking messages as unstarred") + defer storeMailbox.pollNow() + return storeMailbox.api().UnlabelMessages(apiIDs, pmapi.StarredLabel) +} + +// DeleteMessages deletes messages. +// If the mailbox is All Mail or All Sent, it does nothing. +// If the mailbox is Trash or Spam and message is not in any other mailbox, messages is deleted. +// In all other cases the message is only removed from the mailbox. +func (storeMailbox *Mailbox) DeleteMessages(apiIDs []string) error { + log.WithFields(logrus.Fields{ + "messages": apiIDs, + "label": storeMailbox.labelID, + "mailbox": storeMailbox.Name, + }).Trace("Deleting messages") + defer storeMailbox.pollNow() + + switch storeMailbox.labelID { + case pmapi.AllMailLabel, pmapi.AllSentLabel: + break + case pmapi.TrashLabel, pmapi.SpamLabel: + messageIDsToDelete := []string{} + messageIDsToUnlabel := []string{} + for _, apiID := range apiIDs { + msg, err := storeMailbox.store.getMessageFromDB(apiID) + if err != nil { + return err + } + + otherLabels := false + // If the message has any custom label, we don't want to delete it, only remove trash/spam label. + for _, label := range msg.LabelIDs { + if label != pmapi.SpamLabel && label != pmapi.TrashLabel && label != pmapi.AllMailLabel && label != pmapi.AllSentLabel && label != pmapi.DraftLabel && label != pmapi.AllDraftsLabel { + otherLabels = true + break + } + } + + if otherLabels { + messageIDsToUnlabel = append(messageIDsToUnlabel, apiID) + } else { + messageIDsToDelete = append(messageIDsToDelete, apiID) + } + } + if len(messageIDsToUnlabel) > 0 { + if err := storeMailbox.api().UnlabelMessages(messageIDsToUnlabel, storeMailbox.labelID); err != nil { + log.WithError(err).Warning("Cannot unlabel before deleting") + } + } + if len(messageIDsToDelete) > 0 { + if err := storeMailbox.api().DeleteMessages(messageIDsToDelete); err != nil { + return err + } + } + default: + if err := storeMailbox.api().UnlabelMessages(apiIDs, storeMailbox.labelID); err != nil { + return err + } + } + return nil +} + +func (storeMailbox *Mailbox) txSkipAndRemoveFromMailbox(tx *bolt.Tx, msg *pmapi.Message) (skipAndRemove bool) { + defer func() { + if skipAndRemove { + if err := storeMailbox.txDeleteMessage(tx, msg.ID); err != nil { + storeMailbox.log.WithError(err).Error("Cannot remove message") + } + } + }() + + mode, err := storeMailbox.store.getAddressMode() + if err != nil { + log.WithError(err).Error("Could not determine address mode") + return + } + + skipAndRemove = true + + // If it's split mode and it shouldn't be under this address, it should be skipped and removed. + if mode == splitMode && storeMailbox.storeAddress.addressID != msg.AddressID { + return + } + + // If the message belongs in this mailbox, don't skip/remove it. + for _, labelID := range msg.LabelIDs { + if labelID == storeMailbox.labelID { + skipAndRemove = false + return + } + } + + return skipAndRemove +} + +// txCreateOrUpdateMessages will delete, create or update message from mailbox. +func (storeMailbox *Mailbox) txCreateOrUpdateMessages(tx *bolt.Tx, msgs []*pmapi.Message) error { //nolint[funlen] + // Buckets are not initialized right away because it's a heavy operation. + // The best option is to get the same bucket only once and only when needed. + var apiBucket, imapBucket *bolt.Bucket + for _, msg := range msgs { + if storeMailbox.txSkipAndRemoveFromMailbox(tx, msg) { + continue + } + + // Update message. + if apiBucket == nil { + apiBucket = storeMailbox.txGetAPIIDsBucket(tx) + } + + // Draft bodies can change and bodies are not re-fetched by IMAP clients. + // Every change has to be a new message; we need to delete the old one and always recreate it. + if storeMailbox.labelID == pmapi.DraftLabel { + if err := storeMailbox.txDeleteMessage(tx, msg.ID); err != nil { + return errors.Wrap(err, "cannot delete old draft") + } + } else { + uidb := apiBucket.Get([]byte(msg.ID)) + + if uidb != nil { + if imapBucket == nil { + imapBucket = storeMailbox.txGetIMAPIDsBucket(tx) + } + seqNum, seqErr := storeMailbox.txGetSequenceNumberOfUID(imapBucket, uidb) + if seqErr == nil { + storeMailbox.store.imapUpdateMessage( + storeMailbox.storeAddress.address, + storeMailbox.labelName, + btoi(uidb), + seqNum, + msg, + ) + } + continue + } + } + + // Create a new message. + if imapBucket == nil { + imapBucket = storeMailbox.txGetIMAPIDsBucket(tx) + } + uid, err := storeMailbox.txGetNextUID(imapBucket, true) + if err != nil { + return errors.Wrap(err, "cannot generate new UID") + } + uidb := itob(uid) + + if err = imapBucket.Put(uidb, []byte(msg.ID)); err != nil { + return errors.Wrap(err, "cannot add to IMAP bucket") + } + if err = apiBucket.Put([]byte(msg.ID), uidb); err != nil { + return errors.Wrap(err, "cannot add to API bucket") + } + + seqNum, err := storeMailbox.txGetSequenceNumberOfUID(imapBucket, uidb) + if err != nil { + return errors.Wrap(err, "cannot get sequence number from UID") + } + storeMailbox.store.imapUpdateMessage( + storeMailbox.storeAddress.address, + storeMailbox.labelName, + uid, + seqNum, + msg, + ) + } + + return storeMailbox.txMailboxStatusUpdate(tx) +} + +// txDeleteMessage deletes the message from the mailbox bucket. +// and issues message delete and mailbox update changes to updates channel. +func (storeMailbox *Mailbox) txDeleteMessage(tx *bolt.Tx, apiID string) error { + apiBucket := storeMailbox.txGetAPIIDsBucket(tx) + apiIDb := []byte(apiID) + uidb := apiBucket.Get(apiIDb) + if uidb == nil { + return nil + } + + imapBucket := storeMailbox.txGetIMAPIDsBucket(tx) + + seqNum, seqNumErr := storeMailbox.txGetSequenceNumberOfUID(imapBucket, uidb) + if seqNumErr != nil { + storeMailbox.log.WithField("apiID", apiID).WithError(seqNumErr).Warn("Cannot get seqNum of deleting message") + } + + if err := imapBucket.Delete(uidb); err != nil { + return errors.Wrap(err, "cannot delete from IMAP bucket") + } + + if err := apiBucket.Delete(apiIDb); err != nil { + return errors.Wrap(err, "cannot delete from API bucket") + } + + if seqNumErr == nil { + storeMailbox.store.imapDeleteMessage( + storeMailbox.storeAddress.address, + storeMailbox.labelName, + seqNum, + ) + if err := storeMailbox.txMailboxStatusUpdate(tx); err != nil { + return err + } + } + return nil +} + +func (storeMailbox *Mailbox) txMailboxStatusUpdate(tx *bolt.Tx) error { + total, unread, err := storeMailbox.txGetCounts(tx) + if err != nil { + return errors.Wrap(err, "cannot get counts for mailbox status update") + } + storeMailbox.store.imapMailboxStatus( + storeMailbox.storeAddress.address, + storeMailbox.labelName, + total, + unread, + ) + return nil +} diff --git a/internal/store/main_test.go b/internal/store/main_test.go new file mode 100644 index 00000000..492e18e6 --- /dev/null +++ b/internal/store/main_test.go @@ -0,0 +1,31 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "os" + + "github.com/sirupsen/logrus" +) + +func init() { //nolint[gochecknoinits] + logrus.SetLevel(logrus.ErrorLevel) + if os.Getenv("VERBOSITY") == "trace" { + logrus.SetLevel(logrus.TraceLevel) + } +} diff --git a/internal/store/message.go b/internal/store/message.go new file mode 100644 index 00000000..8be26e86 --- /dev/null +++ b/internal/store/message.go @@ -0,0 +1,108 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "net/mail" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + bolt "go.etcd.io/bbolt" +) + +// Message is wrapper around `pmapi.Message` with connection to +// a specific mailbox with helper functions to get IMAP UID, sequence +// numbers and similar. +type Message struct { + api PMAPIProvider + msg *pmapi.Message + + store *Store + storeMailbox *Mailbox +} + +func newStoreMessage(storeMailbox *Mailbox, msg *pmapi.Message) *Message { + return &Message{ + api: storeMailbox.store.api, + msg: msg, + store: storeMailbox.store, + storeMailbox: storeMailbox, + } +} + +// ID returns message ID on our API (always the same ID for all mailboxes). +func (message *Message) ID() string { + return message.msg.ID +} + +// UID returns message UID for IMAP, specific for mailbox used to get the message. +func (message *Message) UID() (uint32, error) { + return message.storeMailbox.getUID(message.ID()) +} + +// SequenceNumber returns index of message in used mailbox. +func (message *Message) SequenceNumber() (uint32, error) { + return message.storeMailbox.getSequenceNumber(message.ID()) +} + +// Message returns message struct from pmapi. +func (message *Message) Message() *pmapi.Message { + return message.msg +} + +// SetSize updates the information about size of decrypted message which can be +// used for IMAP. This should not trigger any IMAP update. +// NOTE: The size from the server corresponds to pure body bytes. Hence it +// should not be used. The correct size has to be calculated from decrypted and +// built message. +func (message *Message) SetSize(size int64) error { + message.msg.Size = size + txUpdate := func(tx *bolt.Tx) error { + stored, err := message.store.txGetMessage(tx, message.msg.ID) + if err != nil { + return err + } + stored.Size = size + return message.store.txPutMessage( + tx.Bucket(metadataBucket), + stored, + ) + } + return message.store.db.Update(txUpdate) +} + +// SetContentTypeAndHeader updates the information about content type and +// header of decrypted message. This should not trigger any IMAP update. +// NOTE: Content type depends on details of decrypted message which we want to +// cache. +func (message *Message) SetContentTypeAndHeader(mimeType string, header mail.Header) error { + message.msg.MIMEType = mimeType + message.msg.Header = header + txUpdate := func(tx *bolt.Tx) error { + stored, err := message.store.txGetMessage(tx, message.msg.ID) + if err != nil { + return err + } + stored.MIMEType = mimeType + stored.Header = header + return message.store.txPutMessage( + tx.Bucket(metadataBucket), + stored, + ) + } + return message.store.db.Update(txUpdate) +} diff --git a/internal/store/mocks/mocks.go b/internal/store/mocks/mocks.go new file mode 100644 index 00000000..0ee60bf1 --- /dev/null +++ b/internal/store/mocks/mocks.go @@ -0,0 +1,193 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/ProtonMail/proton-bridge/internal/store (interfaces: PanicHandler,BridgeUser) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + gomock "github.com/golang/mock/gomock" + reflect "reflect" +) + +// MockPanicHandler is a mock of PanicHandler interface +type MockPanicHandler struct { + ctrl *gomock.Controller + recorder *MockPanicHandlerMockRecorder +} + +// MockPanicHandlerMockRecorder is the mock recorder for MockPanicHandler +type MockPanicHandlerMockRecorder struct { + mock *MockPanicHandler +} + +// NewMockPanicHandler creates a new mock instance +func NewMockPanicHandler(ctrl *gomock.Controller) *MockPanicHandler { + mock := &MockPanicHandler{ctrl: ctrl} + mock.recorder = &MockPanicHandlerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockPanicHandler) EXPECT() *MockPanicHandlerMockRecorder { + return m.recorder +} + +// HandlePanic mocks base method +func (m *MockPanicHandler) HandlePanic() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "HandlePanic") +} + +// HandlePanic indicates an expected call of HandlePanic +func (mr *MockPanicHandlerMockRecorder) HandlePanic() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandlePanic", reflect.TypeOf((*MockPanicHandler)(nil).HandlePanic)) +} + +// MockBridgeUser is a mock of BridgeUser interface +type MockBridgeUser struct { + ctrl *gomock.Controller + recorder *MockBridgeUserMockRecorder +} + +// MockBridgeUserMockRecorder is the mock recorder for MockBridgeUser +type MockBridgeUserMockRecorder struct { + mock *MockBridgeUser +} + +// NewMockBridgeUser creates a new mock instance +func NewMockBridgeUser(ctrl *gomock.Controller) *MockBridgeUser { + mock := &MockBridgeUser{ctrl: ctrl} + mock.recorder = &MockBridgeUserMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockBridgeUser) EXPECT() *MockBridgeUserMockRecorder { + return m.recorder +} + +// CloseConnection mocks base method +func (m *MockBridgeUser) CloseConnection(arg0 string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "CloseConnection", arg0) +} + +// CloseConnection indicates an expected call of CloseConnection +func (mr *MockBridgeUserMockRecorder) CloseConnection(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CloseConnection", reflect.TypeOf((*MockBridgeUser)(nil).CloseConnection), arg0) +} + +// GetAddressID mocks base method +func (m *MockBridgeUser) GetAddressID(arg0 string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAddressID", arg0) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAddressID indicates an expected call of GetAddressID +func (mr *MockBridgeUserMockRecorder) GetAddressID(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAddressID", reflect.TypeOf((*MockBridgeUser)(nil).GetAddressID), arg0) +} + +// GetPrimaryAddress mocks base method +func (m *MockBridgeUser) GetPrimaryAddress() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPrimaryAddress") + ret0, _ := ret[0].(string) + return ret0 +} + +// GetPrimaryAddress indicates an expected call of GetPrimaryAddress +func (mr *MockBridgeUserMockRecorder) GetPrimaryAddress() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPrimaryAddress", reflect.TypeOf((*MockBridgeUser)(nil).GetPrimaryAddress)) +} + +// GetStoreAddresses mocks base method +func (m *MockBridgeUser) GetStoreAddresses() []string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetStoreAddresses") + ret0, _ := ret[0].([]string) + return ret0 +} + +// GetStoreAddresses indicates an expected call of GetStoreAddresses +func (mr *MockBridgeUserMockRecorder) GetStoreAddresses() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetStoreAddresses", reflect.TypeOf((*MockBridgeUser)(nil).GetStoreAddresses)) +} + +// ID mocks base method +func (m *MockBridgeUser) ID() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ID") + ret0, _ := ret[0].(string) + return ret0 +} + +// ID indicates an expected call of ID +func (mr *MockBridgeUserMockRecorder) ID() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ID", reflect.TypeOf((*MockBridgeUser)(nil).ID)) +} + +// IsCombinedAddressMode mocks base method +func (m *MockBridgeUser) IsCombinedAddressMode() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsCombinedAddressMode") + ret0, _ := ret[0].(bool) + return ret0 +} + +// IsCombinedAddressMode indicates an expected call of IsCombinedAddressMode +func (mr *MockBridgeUserMockRecorder) IsCombinedAddressMode() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsCombinedAddressMode", reflect.TypeOf((*MockBridgeUser)(nil).IsCombinedAddressMode)) +} + +// IsConnected mocks base method +func (m *MockBridgeUser) IsConnected() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsConnected") + ret0, _ := ret[0].(bool) + return ret0 +} + +// IsConnected indicates an expected call of IsConnected +func (mr *MockBridgeUserMockRecorder) IsConnected() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsConnected", reflect.TypeOf((*MockBridgeUser)(nil).IsConnected)) +} + +// Logout mocks base method +func (m *MockBridgeUser) Logout() error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Logout") + ret0, _ := ret[0].(error) + return ret0 +} + +// Logout indicates an expected call of Logout +func (mr *MockBridgeUserMockRecorder) Logout() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Logout", reflect.TypeOf((*MockBridgeUser)(nil).Logout)) +} + +// UpdateUser mocks base method +func (m *MockBridgeUser) UpdateUser() error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUser") + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateUser indicates an expected call of UpdateUser +func (mr *MockBridgeUserMockRecorder) UpdateUser() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUser", reflect.TypeOf((*MockBridgeUser)(nil).UpdateUser)) +} diff --git a/internal/store/mocks/utils_mocks.go b/internal/store/mocks/utils_mocks.go new file mode 100644 index 00000000..3f43bb43 --- /dev/null +++ b/internal/store/mocks/utils_mocks.go @@ -0,0 +1,106 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/ProtonMail/proton-bridge/pkg/listener (interfaces: Listener) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + gomock "github.com/golang/mock/gomock" + reflect "reflect" + time "time" +) + +// MockListener is a mock of Listener interface +type MockListener struct { + ctrl *gomock.Controller + recorder *MockListenerMockRecorder +} + +// MockListenerMockRecorder is the mock recorder for MockListener +type MockListenerMockRecorder struct { + mock *MockListener +} + +// NewMockListener creates a new mock instance +func NewMockListener(ctrl *gomock.Controller) *MockListener { + mock := &MockListener{ctrl: ctrl} + mock.recorder = &MockListenerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockListener) EXPECT() *MockListenerMockRecorder { + return m.recorder +} + +// Add mocks base method +func (m *MockListener) Add(arg0 string, arg1 chan<- string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Add", arg0, arg1) +} + +// Add indicates an expected call of Add +func (mr *MockListenerMockRecorder) Add(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Add", reflect.TypeOf((*MockListener)(nil).Add), arg0, arg1) +} + +// Emit mocks base method +func (m *MockListener) Emit(arg0, arg1 string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Emit", arg0, arg1) +} + +// Emit indicates an expected call of Emit +func (mr *MockListenerMockRecorder) Emit(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Emit", reflect.TypeOf((*MockListener)(nil).Emit), arg0, arg1) +} + +// Remove mocks base method +func (m *MockListener) Remove(arg0 string, arg1 chan<- string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Remove", arg0, arg1) +} + +// Remove indicates an expected call of Remove +func (mr *MockListenerMockRecorder) Remove(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Remove", reflect.TypeOf((*MockListener)(nil).Remove), arg0, arg1) +} + +// RetryEmit mocks base method +func (m *MockListener) RetryEmit(arg0 string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "RetryEmit", arg0) +} + +// RetryEmit indicates an expected call of RetryEmit +func (mr *MockListenerMockRecorder) RetryEmit(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RetryEmit", reflect.TypeOf((*MockListener)(nil).RetryEmit), arg0) +} + +// SetBuffer mocks base method +func (m *MockListener) SetBuffer(arg0 string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetBuffer", arg0) +} + +// SetBuffer indicates an expected call of SetBuffer +func (mr *MockListenerMockRecorder) SetBuffer(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetBuffer", reflect.TypeOf((*MockListener)(nil).SetBuffer), arg0) +} + +// SetLimit mocks base method +func (m *MockListener) SetLimit(arg0 string, arg1 time.Duration) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetLimit", arg0, arg1) +} + +// SetLimit indicates an expected call of SetLimit +func (mr *MockListenerMockRecorder) SetLimit(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetLimit", reflect.TypeOf((*MockListener)(nil).SetLimit), arg0, arg1) +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 00000000..5952890c --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,396 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Package store communicates with API and caches metadata in a local database. +package store + +import ( + "fmt" + "os" + "sync" + "time" + + "github.com/ProtonMail/proton-bridge/pkg/listener" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/hashicorp/go-multierror" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + bolt "go.etcd.io/bbolt" +) + +const ( + // PathDelimiter for IMAP + PathDelimiter = "/" + // UserLabelsMailboxName for IMAP + UserLabelsMailboxName = "Labels" + // UserLabelsPrefix contains name with delimiter for IMAP + UserLabelsPrefix = UserLabelsMailboxName + PathDelimiter + // UserFoldersMailboxName for IMAP + UserFoldersMailboxName = "Folders" + // UserFoldersPrefix contains name with delimiter for IMAP + UserFoldersPrefix = UserFoldersMailboxName + PathDelimiter +) + +var ( + log = logrus.WithField("pkg", "store") //nolint[gochecknoglobals] + + // Database structure: + // * metadata + // * {messageID} -> message data (subject, from, to, time, headers, body size, ...) + // * counts + // * {mailboxID} -> mailboxCounts: totalOnAPI, unreadOnAPI, labelName, labelColor, labelIsExclusive + // * address_info + // * {index} -> {address, addressID} + // * address_mode + // * mode -> string split or combined + // * mailboxes_version + // * version -> uint32 value + // * sync_state + // * sync_state -> string timestamp when it was last synced (when missing, sync should be ongoing) + // * ids_ranges -> json array of groups with start and end message ID (when missing, there is no ongoing sync) + // * ids_to_be_deleted -> json array of message IDs to be deleted after sync (when missing, there is no ongoing sync) + // * mailboxes + // * {addressID+mailboxID} + // * imap_ids + // * {imapUID} -> string messageID + // * api_ids + // * {messageID} -> uint32 imapUID + metadataBucket = []byte("metadata") //nolint[gochecknoglobals] + countsBucket = []byte("counts") //nolint[gochecknoglobals] + addressInfoBucket = []byte("address_info") //nolint[gochecknoglobals] + addressModeBucket = []byte("address_mode") //nolint[gochecknoglobals] + syncStateBucket = []byte("sync_state") //nolint[gochecknoglobals] + mailboxesBucket = []byte("mailboxes") //nolint[gochecknoglobals] + imapIDsBucket = []byte("imap_ids") //nolint[gochecknoglobals] + apiIDsBucket = []byte("api_ids") //nolint[gochecknoglobals] + mboxVersionBucket = []byte("mailboxes_version") //nolint[gochecknoglobals] + + // ErrNoSuchAPIID when mailbox does not have API ID. + ErrNoSuchAPIID = errors.New("no such api id") //nolint[gochecknoglobals] + // ErrNoSuchUID when mailbox does not have IMAP UID. + ErrNoSuchUID = errors.New("no such uid") //nolint[gochecknoglobals] + // ErrNoSuchSeqNum when mailbox does not have IMAP ID. + ErrNoSuchSeqNum = errors.New("no such sequence number") //nolint[gochecknoglobals] +) + +// Store is local user storage, which handles the synchronization between IMAP and PM API. +type Store struct { + panicHandler PanicHandler + eventLoop *eventLoop + user BridgeUser + api PMAPIProvider + + log *logrus.Entry + + cache *Cache + filePath string + db *bolt.DB + lock *sync.RWMutex + addresses map[string]*Address + imapUpdates chan interface{} + + isSyncRunning bool + addressMode addressMode +} + +// New creates or opens a store for the given `user`. +func New( + panicHandler PanicHandler, + user BridgeUser, + api PMAPIProvider, + events listener.Listener, + path string, + cache *Cache, +) (store *Store, err error) { + if user == nil || api == nil || events == nil || cache == nil { + return nil, fmt.Errorf("missing parameters - user: %v, api: %v, events: %v, cache: %v", user, api, events, cache) + } + + l := log.WithField("user", user.ID()) + + var firstInit bool + if _, existErr := os.Stat(path); os.IsNotExist(existErr) { + l.Info("Creating new store database file with address mode from user's credentials store") + firstInit = true + } else { + l.Info("Store database file already exists, using mode already set") + firstInit = false + } + + bdb, err := openBoltDatabase(path) + if err != nil { + err = errors.Wrap(err, "failed to open store database") + return + } + + store = &Store{ + panicHandler: panicHandler, + api: api, + user: user, + cache: cache, + filePath: path, + db: bdb, + lock: &sync.RWMutex{}, + log: l, + } + + if err = store.init(firstInit); err != nil { + l.WithError(err).Error("Could not initialise store, attempting to close") + if storeCloseErr := store.Close(); storeCloseErr != nil { + l.WithError(storeCloseErr).Warn("Could not close uninitialised store") + } + err = errors.Wrap(err, "failed to initialise store") + return + } + + if user.IsConnected() { + store.eventLoop = newEventLoop(cache, store, api, user, events) + go func() { + defer store.panicHandler.HandlePanic() + store.eventLoop.start() + }() + } + + return store, err +} + +func openBoltDatabase(filePath string) (db *bolt.DB, err error) { + l := log.WithField("path", filePath) + l.Debug("Opening bolt database") + + if db, err = bolt.Open(filePath, 0600, &bolt.Options{Timeout: 1 * time.Second}); err != nil { + l.WithError(err).Error("Could not open bolt database") + return + } + + if val, set := os.LookupEnv("BRIDGESTRICTMODE"); set && val == "1" { + db.StrictMode = true + } + + tx := func(tx *bolt.Tx) (err error) { + if _, err = tx.CreateBucketIfNotExists(metadataBucket); err != nil { + return + } + + if _, err = tx.CreateBucketIfNotExists(countsBucket); err != nil { + return + } + + if _, err = tx.CreateBucketIfNotExists(addressInfoBucket); err != nil { + return + } + + if _, err = tx.CreateBucketIfNotExists(addressModeBucket); err != nil { + return + } + + if _, err = tx.CreateBucketIfNotExists(syncStateBucket); err != nil { + return + } + + if _, err = tx.CreateBucketIfNotExists(mailboxesBucket); err != nil { + return + } + + if _, err = tx.CreateBucketIfNotExists(mboxVersionBucket); err != nil { + return + } + + return + } + + if err = db.Update(tx); err != nil { + return + } + + return db, err +} + +// init initialises the store for the given addresses. +func (store *Store) init(firstInit bool) (err error) { + if store.addresses != nil { + store.log.Warn("Store was already initialised") + return + } + + // If it's the first time we are creating the store, use the mode set in the + // user's credentials, otherwise read it from the DB (if present). + if firstInit { + if store.user.IsCombinedAddressMode() { + err = store.setAddressMode(combinedMode) + } else { + err = store.setAddressMode(splitMode) + } + if err != nil { + return errors.Wrap(err, "first init setting store address mode") + } + } else if store.addressMode, err = store.getAddressMode(); err != nil { + store.log.WithError(err).Error("Store address mode is unknown, setting to combined mode") + if err = store.setAddressMode(combinedMode); err != nil { + return errors.Wrap(err, "setting store address mode") + } + } + + store.log.WithField("mode", store.addressMode).Debug("Initialising store") + + labels, err := store.initCounts() + if err != nil { + store.log.WithError(err).Error("Could not initialise label counts") + return + } + + if err = store.initAddresses(labels); err != nil { + store.log.WithError(err).Error("Could not initialise store addresses") + return + } + + return err +} + +// initCounts initialises the counts for each label. It tries to use the API first to fetch the labels but if +// the API is unavailable for whatever reason it tries to fetch the labels locally. +func (store *Store) initCounts() (labels []*pmapi.Label, err error) { + if labels, err = store.api.ListLabels(); err != nil { + store.log.WithError(err).Warn("Could not list API labels. Trying with local labels.") + if labels, err = store.getLabelsFromLocalStorage(); err != nil { + store.log.WithError(err).Error("Cannot list local labels") + return + } + } else { + // the labels listed by PMAPI don't include system folders so we need to add them. + for _, counts := range getSystemFolders() { + labels = append(labels, counts.getPMLabel()) + } + + if err = store.createOrUpdateMailboxCountsBuckets(labels); err != nil { + store.log.WithError(err).Error("Cannot create counts") + return + } + + if countsErr := store.updateCountsFromServer(); countsErr != nil { + store.log.WithError(countsErr).Warning("Continue without new counts from server") + } + } + + sortByOrder(labels) + + return +} + +// initAddresses creates address objects in the store for each necessary address. +// In combined mode this means just one mailbox for all addresses but in split mode this means one mailbox per address. +func (store *Store) initAddresses(labels []*pmapi.Label) (err error) { + store.addresses = make(map[string]*Address) + + addrInfo, err := store.GetAddressInfo() + if err != nil { + store.log.WithError(err).Error("Could not get addresses and address IDs") + return + } + + // We need at least one address to continue. + if len(addrInfo) < 1 { + err = errors.New("no addresses to initialise") + store.log.WithError(err).Warn("There are no addresses to initialise") + return + } + + // If in combined mode, we only need the user's primary address. + if store.addressMode == combinedMode { + addrInfo = addrInfo[:1] + } + + for _, addr := range addrInfo { + if err = store.addAddress(addr.Address, addr.AddressID, labels); err != nil { + store.log.WithField("address", addr.Address).WithError(err).Error("Could not add address to store") + } + } + + return err +} + +// addAddress adds a new address to the store. If the address exists already it is overwritten. +func (store *Store) addAddress(address, addressID string, labels []*pmapi.Label) (err error) { + if _, ok := store.addresses[addressID]; ok { + store.log.WithField("addressID", addressID).Debug("Overwriting store address") + } + + addr, err := newAddress(store, address, addressID, labels) + if err != nil { + return errors.Wrap(err, "failed to create store address object") + } + + store.addresses[addressID] = addr + + return +} + +// Close stops the event loop and closes the database to free the file. +func (store *Store) Close() error { + store.lock.Lock() + defer store.lock.Unlock() + + return store.close() +} + +// CloseEventLoop stops the eventloop (if it is present). +func (store *Store) CloseEventLoop() { + if store.eventLoop != nil { + store.eventLoop.stop() + } +} + +func (store *Store) close() error { + store.CloseEventLoop() + return store.db.Close() +} + +// Remove closes and removes the database file and clears the cache file. +func (store *Store) Remove() (err error) { + store.lock.Lock() + defer store.lock.Unlock() + + store.log.Trace("Removing store") + + var result *multierror.Error + + if err = store.close(); err != nil { + result = multierror.Append(result, errors.Wrap(err, "failed to close store")) + } + + if err = RemoveStore(store.cache, store.filePath, store.user.ID()); err != nil { + result = multierror.Append(result, errors.Wrap(err, "failed to remove store")) + } + + return result.ErrorOrNil() +} + +// RemoveStore removes the database file and clears the cache file. +func RemoveStore(cache *Cache, path, userID string) error { + var result *multierror.Error + + if err := cache.clearCacheUser(userID); err != nil { + result = multierror.Append(result, errors.Wrap(err, "failed to clear event loop user cache")) + } + + // RemoveAll will not return an error if the path does not exist. + if err := os.RemoveAll(path); err != nil { + result = multierror.Append(result, errors.Wrap(err, "failed to remove database file")) + } + + return result.ErrorOrNil() +} diff --git a/internal/store/store_address_mode.go b/internal/store/store_address_mode.go new file mode 100644 index 00000000..0f394fd6 --- /dev/null +++ b/internal/store/store_address_mode.go @@ -0,0 +1,112 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "github.com/pkg/errors" + bolt "go.etcd.io/bbolt" +) + +type addressMode string + +const ( + splitMode addressMode = "split" + combinedMode addressMode = "combined" + modeKey = "mode" +) + +// getAddressMode returns the current address mode (split or combined) of the store. +// It first looks in the local cache but if that is not yet set, it loads it from the database. +func (store *Store) getAddressMode() (mode addressMode, err error) { + if store.addressMode != "" { + mode = store.addressMode + return + } + + tx := func(tx *bolt.Tx) (err error) { + b := tx.Bucket(addressModeBucket) + + dbMode := b.Get([]byte(modeKey)) + if dbMode == nil { + return errors.New("address mode not set") + } + + mode = addressMode(dbMode) + + return + } + + err = store.db.View(tx) + + return +} + +// IsCombinedMode returns whether the store is set to combined mode. +func (store *Store) IsCombinedMode() bool { + return store.addressMode == combinedMode +} + +// UseCombinedMode sets whether the store should be set to combined mode. +func (store *Store) UseCombinedMode(useCombined bool) (err error) { + if useCombined { + err = store.switchAddressMode(combinedMode) + } else { + err = store.switchAddressMode(splitMode) + } + + return +} + +// switchAddressMode sets the address mode to the given value and rebuilds the mailboxes. +func (store *Store) switchAddressMode(mode addressMode) (err error) { + if store.addressMode == mode { + log.Debug("The store is using the correct address mode") + return + } + + if err = store.setAddressMode(mode); err != nil { + log.WithError(err).Error("Could not set store address mode") + return + } + + if err = store.RebuildMailboxes(); err != nil { + log.WithError(err).Error("Could not rebuild mailboxes after switching address mode") + return + } + + return +} + +// setAddressMode sets the current address mode (split or combined) of the store. +// It writes to database and updates the local value in the store object. +func (store *Store) setAddressMode(mode addressMode) (err error) { + store.log.WithField("mode", string(mode)).Info("Setting store address mode") + + tx := func(tx *bolt.Tx) (err error) { + b := tx.Bucket(addressModeBucket) + return b.Put([]byte(modeKey), []byte(mode)) + } + + if err = store.db.Update(tx); err != nil { + return + } + + store.addressMode = mode + + return +} diff --git a/internal/store/store_structure_version.go b/internal/store/store_structure_version.go new file mode 100644 index 00000000..92f62510 --- /dev/null +++ b/internal/store/store_structure_version.go @@ -0,0 +1,68 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import bolt "go.etcd.io/bbolt" + +const ( + versionKey = "version" + + // versionOffset makes it possible to force email client to reload all + // mailboxes. If increased during application update it will trigger + // the reload on client side without needing to sync DB or re-setup account. + versionOffset = uint32(3) +) + +func (store *Store) getMailboxesVersion() uint32 { + localVersion := store.readMailboxesVersion() + // If a read error occurs it returns 0 which is an invalid version value. + if localVersion == 0 { + localVersion = 1 + _ = store.writeMailboxesVersion(localVersion) + } + + // versionOffset will make email clients reload if increased during bridge update. + return localVersion + versionOffset +} + +func (store *Store) increaseMailboxesVersion() error { + ver := store.readMailboxesVersion() + // The version is zero if a read error occurred. Operation ++ will make it 1 + // which is default starting value. + ver++ + return store.writeMailboxesVersion(ver) +} + +func (store *Store) readMailboxesVersion() (version uint32) { + _ = store.db.View(func(tx *bolt.Tx) (err error) { + b := tx.Bucket(mboxVersionBucket) + verRaw := b.Get([]byte(versionKey)) + if verRaw != nil { + version = btoi(verRaw) + } + return nil + }) + return +} + +func (store *Store) writeMailboxesVersion(ver uint32) error { + return store.db.Update(func(tx *bolt.Tx) (err error) { + b := tx.Bucket(mboxVersionBucket) + return b.Put([]byte(versionKey), itob(ver)) + }) +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 00000000..2859fbde --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,129 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "io/ioutil" + "os" + "path/filepath" + "sync" + "testing" + + bridgemocks "github.com/ProtonMail/proton-bridge/internal/bridge/mocks" + storeMocks "github.com/ProtonMail/proton-bridge/internal/store/mocks" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/golang/mock/gomock" + + "github.com/stretchr/testify/require" +) + +const ( + addr1 = "niceaddress@pm.me" + addrID1 = "niceaddressID" + + addr2 = "jamesandmichalarecool@pm.me" + addrID2 = "jamesandmichalarecool" +) + +type mocksForStore struct { + tb testing.TB + + ctrl *gomock.Controller + events *storeMocks.MockListener + api *bridgemocks.MockPMAPIProvider + user *storeMocks.MockBridgeUser + panicHandler *storeMocks.MockPanicHandler + store *Store + + tmpDir string + cache *Cache +} + +func initMocks(tb testing.TB) (*mocksForStore, func()) { + ctrl := gomock.NewController(tb) + mocks := &mocksForStore{ + tb: tb, + ctrl: ctrl, + events: storeMocks.NewMockListener(ctrl), + api: bridgemocks.NewMockPMAPIProvider(ctrl), + user: storeMocks.NewMockBridgeUser(ctrl), + panicHandler: storeMocks.NewMockPanicHandler(ctrl), + } + + // Called during clean-up. + mocks.panicHandler.EXPECT().HandlePanic().AnyTimes() + + var err error + mocks.tmpDir, err = ioutil.TempDir("", "store-test") + require.NoError(tb, err) + + cacheFile := filepath.Join(mocks.tmpDir, "cache.json") + mocks.cache = NewCache(cacheFile) + + return mocks, func() { + if err := recover(); err != nil { + panic(err) + } + if mocks.store != nil { + require.Nil(tb, mocks.store.Close()) + } + ctrl.Finish() + require.NoError(tb, os.RemoveAll(mocks.tmpDir)) + } +} + +func (mocks *mocksForStore) newStoreNoEvents(combinedMode bool) { //nolint[unparam] + mocks.user.EXPECT().ID().Return("userID").AnyTimes() + mocks.user.EXPECT().IsConnected().Return(true) + mocks.user.EXPECT().IsCombinedAddressMode().Return(combinedMode) + + mocks.api.EXPECT().Addresses().Return(pmapi.AddressList{ + {ID: addrID1, Email: addr1, Type: pmapi.OriginalAddress, Receive: pmapi.CanReceive}, + {ID: addrID2, Email: addr2, Type: pmapi.AliasAddress, Receive: pmapi.CanReceive}, + }) + mocks.api.EXPECT().ListLabels() + mocks.api.EXPECT().CountMessages("") + mocks.api.EXPECT().GetEvent(gomock.Any()). + Return(&pmapi.Event{ + EventID: "latestEventID", + }, nil).AnyTimes() + + // We want to wait until first sync has finished. + firstSyncWaiter := sync.WaitGroup{} + firstSyncWaiter.Add(1) + mocks.api.EXPECT(). + ListMessages(gomock.Any()). + DoAndReturn(func(*pmapi.MessagesFilter) ([]*pmapi.Message, int, error) { + firstSyncWaiter.Done() + return []*pmapi.Message{}, 0, nil + }) + + var err error + mocks.store, err = New( + mocks.panicHandler, + mocks.user, + mocks.api, + mocks.events, + filepath.Join(mocks.tmpDir, "mailbox-test.db"), + mocks.cache, + ) + require.NoError(mocks.tb, err) + + // Wait for sync to finish. + firstSyncWaiter.Wait() +} diff --git a/internal/store/store_test_exports.go b/internal/store/store_test_exports.go new file mode 100644 index 00000000..7248c249 --- /dev/null +++ b/internal/store/store_test_exports.go @@ -0,0 +1,145 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "encoding/json" + "fmt" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/stretchr/testify/assert" + bolt "go.etcd.io/bbolt" +) + +// TestSync triggers a sync of the store. +func (store *Store) TestSync() { + store.triggerSync() +} + +// TestPollNow triggers a loop of the event loop. +func (store *Store) TestPollNow() { + store.eventLoop.pollNow() +} + +// TestIsSyncRunning returns whether the sync is currently ongoing. +func (store *Store) TestIsSyncRunning() bool { + return store.isSyncRunning +} + +// TestGetEventLoop returns the store's event loop. +func (store *Store) TestGetEventLoop() *eventLoop { //nolint[golint] + return store.eventLoop +} + +// TestGetStoreFilePath returns the filepath of the store's database file. +func (store *Store) TestGetStoreFilePath() string { + return store.filePath +} + +// TestDumpDB will dump store database content. +func (store *Store) TestDumpDB(tb assert.TestingT) { + dumpCounts := true + fmt.Printf(">>>>>>>> DUMP %s <<<<<\n\n", store.db.Path()) + + txMails := txDumpMailsFactory(tb) + + txDump := func(tx *bolt.Tx) error { + if dumpCounts { + if err := txDumpCounts(tx); err != nil { + return err + } + } + if err := txMails(tx); err != nil { + return err + } + return nil + } + + assert.NoError(tb, store.db.View(txDump)) +} + +func txDumpMailsFactory(tb assert.TestingT) func(tx *bolt.Tx) error { + return func(tx *bolt.Tx) error { + mailboxes := tx.Bucket(mailboxesBucket) + metadata := tx.Bucket(metadataBucket) + err := mailboxes.ForEach(func(mboxName, mboxData []byte) error { + fmt.Println("mbox:", string(mboxName)) + b := mailboxes.Bucket(mboxName).Bucket(imapIDsBucket) + c := b.Cursor() + i := 0 + for imapID, apiID := c.First(); imapID != nil; imapID, apiID = c.Next() { + i++ + fmt.Println(" ", i, "imap", btoi(imapID), "api", string(apiID)) + data := metadata.Get(apiID) + if !assert.NotNil(tb, data) { + continue + } + if !assert.NoError(tb, txMailMeta(data, i)) { + continue + } + } + fmt.Println("total:", i) + return nil + }) + return err + } +} + +func txDumpCounts(tx *bolt.Tx) error { + counts := tx.Bucket(countsBucket) + err := counts.ForEach(func(labelID, countsB []byte) error { + defer fmt.Println() + fmt.Printf("counts id: %q ", string(labelID)) + counts := &mailboxCounts{} + if err := json.Unmarshal(countsB, counts); err != nil { + fmt.Printf(" Error %v", err) + return nil + } + fmt.Printf(" total :%d unread %d", counts.TotalOnAPI, counts.UnreadOnAPI) + return nil + }) + return err +} + +func txMailMeta(data []byte, i int) error { + fullMetaDump := false + msg := &pmapi.Message{} + if err := json.Unmarshal(data, msg); err != nil { + return err + } + if msg.Body != "" { + fmt.Printf(" %d body %s\n\n", i, msg.Body) + panic("NONZERO BODY") + } + if i >= 10 { + return nil + } + if fullMetaDump { + fmt.Printf(" %d meta %s\n\n", i, string(data)) + } else { + fmt.Println( + " Subj", msg.Subject, + "\n From", msg.Sender, + "\n Time", msg.Time, + "\n Labels", msg.LabelIDs, + "\n Unread", msg.Unread, + ) + } + + return nil +} diff --git a/internal/store/sync.go b/internal/store/sync.go new file mode 100644 index 00000000..9a83cdbe --- /dev/null +++ b/internal/store/sync.go @@ -0,0 +1,222 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "math" + "sync" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/pkg/errors" +) + +const ( + syncMinPagesPerWorker = 10 + syncMessagesMaxWorkers = 5 + maxFilterPageSize = 150 +) + +type storeSynchronizer interface { + getAllMessageIDs() ([]string, error) + createOrUpdateMessagesEvent([]*pmapi.Message) error + deleteMessagesEvent([]string) error + saveSyncState(finishTime int64, idRanges []*syncIDRange, idsToBeDeleted []string) +} + +type messageLister interface { + ListMessages(*pmapi.MessagesFilter) ([]*pmapi.Message, int, error) +} + +func syncAllMail(panicHandler PanicHandler, store storeSynchronizer, api messageLister, syncState *syncState) error { + labelID := pmapi.AllMailLabel + + // When the full sync starts (i.e. is not already in progress), we need to load + // - all message IDs in database, so we can see which messages we need to remove at the end of the sync + // - ID ranges which indicate how to split work into multiple workers + if !syncState.isIncomplete() { + if err := syncState.loadMessageIDsToBeDeleted(); err != nil { + return errors.Wrap(err, "failed to load message IDs") + } + + if err := findIDRanges(labelID, api, syncState); err != nil { + return errors.Wrap(err, "failed to load IDs ranges") + } + syncState.save() + } + + wg := &sync.WaitGroup{} + + shouldStop := 0 // Using integer to have it atomic. + var resultError error + + for _, idRange := range syncState.idRanges { + wg.Add(1) + idRange := idRange // Bind for goroutine. + go func() { + defer panicHandler.HandlePanic() + defer wg.Done() + + err := syncBatch(labelID, store, api, syncState, idRange, &shouldStop) + if err != nil { + shouldStop = 1 + resultError = errors.Wrap(err, "failed to sync group") + } + }() + } + + wg.Wait() + + if resultError == nil { + if err := syncState.deleteMessagesToBeDeleted(); err != nil { + return errors.Wrap(err, "failed to delete messages") + } + } + + return resultError +} + +func findIDRanges(labelID string, api messageLister, syncState *syncState) error { + _, count, err := getSplitIDAndCount(labelID, api, 0) + if err != nil { + return errors.Wrap(err, "failed to get first ID and count") + } + log.WithField("total", count).Debug("Finding ID ranges") + if count == 0 { + return nil + } + + syncState.initIDRanges() + + pages := int(math.Ceil(float64(count) / float64(maxFilterPageSize))) + workers := (pages / syncMinPagesPerWorker) + 1 + if workers > syncMessagesMaxWorkers { + workers = syncMessagesMaxWorkers + } + + if workers == 1 { + return nil + } + + step := int(math.Round(float64(pages) / float64(workers))) + // Increment steps in case there are more steps than max # of workers (due to rounding). + if (step*syncMessagesMaxWorkers)+1 < pages { + step++ + } + + for page := step; page < pages; page += step { + splitID, _, err := getSplitIDAndCount(labelID, api, page) + if err != nil { + return errors.Wrap(err, "failed to get IDs range") + } + // Some messages were probably deleted and so the page does not exist anymore. + // Would be good to start this function again, but let's rather start the sync instead of + // wasting time of many calls to API to find where to split workers. + if splitID == "" { + break + } + syncState.addIDRange(splitID) + } + + return nil +} + +func getSplitIDAndCount(labelID string, api messageLister, page int) (string, int, error) { + sort := "ID" + desc := false + filter := &pmapi.MessagesFilter{ + LabelID: labelID, + Sort: sort, + Desc: &desc, + PageSize: maxFilterPageSize, + Page: page, + Limit: 1, + } + // If the page does not exist, an empty page instead of an error is returned. + messages, total, err := api.ListMessages(filter) + if err != nil { + return "", 0, errors.Wrap(err, "failed to list messages") + } + if len(messages) == 0 { + return "", 0, nil + } + return messages[0].ID, total, nil +} + +func syncBatch( //nolint[funlen] + labelID string, + store storeSynchronizer, + api messageLister, + syncState *syncState, + idRange *syncIDRange, + shouldStop *int, +) error { + log.WithField("start", idRange.StartID).WithField("stop", idRange.StopID).Info("Starting sync batch") + for { + if *shouldStop == 1 || idRange.isFinished() { + break + } + + sort := "ID" + desc := true + filter := &pmapi.MessagesFilter{ + LabelID: labelID, + Sort: sort, + Desc: &desc, + PageSize: maxFilterPageSize, + Page: 0, + + // Messages with BeginID and EndID are included. We will process + // those messages twice, but that's OK. + // When message is completely removed, it still works as expected. + BeginID: idRange.StartID, + EndID: idRange.StopID, + } + + log.WithField("begin", filter.BeginID).WithField("end", filter.EndID).Debug("Fetching page") + + messages, _, err := api.ListMessages(filter) + if err != nil { + return errors.Wrap(err, "failed to list messages") + } + + if len(messages) == 0 { + break + } + + for _, m := range messages { + syncState.doNotDeleteMessageID(m.ID) + } + syncState.save() + + if err := store.createOrUpdateMessagesEvent(messages); err != nil { + return errors.Wrap(err, "failed to create or update messages") + } + + pageLastMessageID := messages[len(messages)-1].ID + if !desc { + idRange.setStartID(pageLastMessageID) + } else { + idRange.setStopID(pageLastMessageID) + } + + if len(messages) < maxFilterPageSize { + break + } + } + return nil +} diff --git a/internal/store/sync_state.go b/internal/store/sync_state.go new file mode 100644 index 00000000..c088522b --- /dev/null +++ b/internal/store/sync_state.go @@ -0,0 +1,217 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "sync" + "time" + + "github.com/pkg/errors" +) + +type syncState struct { + lock *sync.RWMutex + store storeSynchronizer + + // finishTime is the time, when the sync was finished for the last time. + // When it's zero, it was never finished or the sync is ongoing. + finishTime int64 + + // idRanges are ID ranges which are used to split work in several workers. + // On the beginning of the sync it will find split IDs which are used to + // create this ranges. If we have 10000 messages and five workers, it will + // find IDs around 2000, 4000, 6000 and 8000 and then first worker will + // sync IDs 0-2000, second 2000-4000 and so on. + idRanges []*syncIDRange + + // idsToBeDeletedMap is map with keys as message IDs. On the beginning + // of the sync, it will load all message IDs in database. During the sync, + // it will delete all messages from the map which were sycned. The rest + // at the end of the sync will be removed as those messages were not synced + // again. We do that because we don't want to remove everything on the + // beginning of the sync to keep client synced. + idsToBeDeletedMap map[string]bool +} + +func newSyncState(store storeSynchronizer, finishTime int64, idRanges []*syncIDRange, idsToBeDeleted []string) *syncState { + idsToBeDeletedMap := map[string]bool{} + for _, id := range idsToBeDeleted { + idsToBeDeletedMap[id] = true + } + + syncState := &syncState{ + lock: &sync.RWMutex{}, + store: store, + + finishTime: finishTime, + idRanges: idRanges, + idsToBeDeletedMap: idsToBeDeletedMap, + } + + for _, idRange := range idRanges { + idRange.syncState = syncState + } + + return syncState +} + +func (s *syncState) save() { + s.lock.Lock() + defer s.lock.Unlock() + + s.store.saveSyncState(s.finishTime, s.idRanges, s.getIDsToBeDeleted()) +} + +// isIncomplete returns whether the sync is in progress (no matter whether +// the sync is running or just not finished by info from database). +func (s *syncState) isIncomplete() bool { + s.lock.Lock() + defer s.lock.Unlock() + + return s.finishTime == 0 && len(s.idRanges) != 0 +} + +// isFinished returns whether the sync was finished. +func (s *syncState) isFinished() bool { + s.lock.Lock() + defer s.lock.Unlock() + + return s.finishTime != 0 +} + +// clearFinishTime sets finish time to zero. +func (s *syncState) clearFinishTime() { + s.lock.Lock() + defer s.save() + defer s.lock.Unlock() + + s.finishTime = 0 +} + +// setFinishTime sets finish time to current time. +func (s *syncState) setFinishTime() { + s.lock.Lock() + defer s.save() + defer s.lock.Unlock() + + s.finishTime = time.Now().UnixNano() +} + +// initIDRanges inits the main full range. Then each range is added +// by `addIDRange`. +func (s *syncState) initIDRanges() { + s.lock.Lock() + defer s.lock.Unlock() + + s.idRanges = []*syncIDRange{{ + syncState: s, + StartID: "", + StopID: "", + }} +} + +// addIDRange sets `splitID` as stopID for last range and adds new one +// starting with `splitID`. +func (s *syncState) addIDRange(splitID string) { + s.lock.Lock() + defer s.lock.Unlock() + + lastGroup := s.idRanges[len(s.idRanges)-1] + lastGroup.StopID = splitID + + s.idRanges = append(s.idRanges, &syncIDRange{ + syncState: s, + StartID: splitID, + StopID: "", + }) +} + +// loadMessageIDsToBeDeleted loads all message IDs from database +// and by default all IDs are meant for deletion. During sync for +// each ID `doNotDeleteMessageID` has to be called to remove that +// message from being deleted by `deleteMessagesToBeDeleted`. +func (s *syncState) loadMessageIDsToBeDeleted() error { + idsToBeDeletedMap := make(map[string]bool) + ids, err := s.store.getAllMessageIDs() + if err != nil { + return err + } + for _, id := range ids { + idsToBeDeletedMap[id] = true + } + + s.lock.Lock() + defer s.save() + defer s.lock.Unlock() + + s.idsToBeDeletedMap = idsToBeDeletedMap + return nil +} + +func (s *syncState) doNotDeleteMessageID(id string) { + s.lock.Lock() + defer s.lock.Unlock() + + delete(s.idsToBeDeletedMap, id) +} + +func (s *syncState) deleteMessagesToBeDeleted() error { + s.lock.Lock() + defer s.lock.Unlock() + + idsToBeDeleted := s.getIDsToBeDeleted() + log.Infof("Deleting %v messages after sync", len(idsToBeDeleted)) + if err := s.store.deleteMessagesEvent(idsToBeDeleted); err != nil { + return errors.Wrap(err, "failed to delete messages") + } + return nil +} + +// getIDsToBeDeleted is helper to convert internal map for easier +// manipulation to array. +func (s *syncState) getIDsToBeDeleted() []string { + keys := []string{} + for key := range s.idsToBeDeletedMap { + keys = append(keys, key) + } + return keys +} + +// syncIDRange holds range which IDs need to be synced. +type syncIDRange struct { + syncState *syncState + StartID string + StopID string +} + +func (r *syncIDRange) setStartID(startID string) { + r.StartID = startID + r.syncState.save() +} + +func (r *syncIDRange) setStopID(stopID string) { + r.StopID = stopID + r.syncState.save() +} + +// isFinished returns syncIDRange is finished when StartID and StopID +// are the same. But it cannot be full range, full range cannot be +// determined in other way than asking API. +func (r *syncIDRange) isFinished() bool { + return r.StartID == r.StopID && r.StartID != "" +} diff --git a/internal/store/sync_state_test.go b/internal/store/sync_state_test.go new file mode 100644 index 00000000..d28d0c30 --- /dev/null +++ b/internal/store/sync_state_test.go @@ -0,0 +1,85 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSyncState_IDRanges(t *testing.T) { + store := &mockStoreSynchronizer{} + syncState := newSyncState(store, 0, []*syncIDRange{}, []string{}) + + syncState.initIDRanges() + syncState.addIDRange("100") + syncState.addIDRange("200") + + r := syncState.idRanges + assert.Equal(t, "", r[0].StartID) + assert.Equal(t, "100", r[0].StopID) + assert.Equal(t, "100", r[1].StartID) + assert.Equal(t, "200", r[1].StopID) + assert.Equal(t, "200", r[2].StartID) + assert.Equal(t, "", r[2].StopID) +} + +func TestSyncState_IDRangesLoaded(t *testing.T) { + store := &mockStoreSynchronizer{} + syncState := newSyncState(store, 0, []*syncIDRange{ + {StartID: "", StopID: "100"}, + {StartID: "100", StopID: ""}, + }, []string{}) + + r := syncState.idRanges + assert.Equal(t, "", r[0].StartID) + assert.Equal(t, "100", r[0].StopID) + assert.Equal(t, "100", r[1].StartID) + assert.Equal(t, "", r[1].StopID) +} + +func TestSyncState_IDsToBeDeleted(t *testing.T) { + store := &mockStoreSynchronizer{ + allMessageIDs: generateIDs(1, 9), + } + syncState := newSyncState(store, 0, []*syncIDRange{}, []string{}) + + require.Nil(t, syncState.loadMessageIDsToBeDeleted()) + syncState.doNotDeleteMessageID("1") + syncState.doNotDeleteMessageID("2") + syncState.doNotDeleteMessageID("3") + syncState.doNotDeleteMessageID("notthere") + + idsToBeDeleted := syncState.getIDsToBeDeleted() + sort.Strings(idsToBeDeleted) + assert.Equal(t, generateIDs(4, 9), idsToBeDeleted) +} + +func TestSyncState_IDsToBeDeletedLoaded(t *testing.T) { + store := &mockStoreSynchronizer{ + allMessageIDs: generateIDs(1, 9), + } + syncState := newSyncState(store, 0, []*syncIDRange{}, generateIDs(4, 9)) + + idsToBeDeleted := syncState.getIDsToBeDeleted() + sort.Strings(idsToBeDeleted) + assert.Equal(t, generateIDs(4, 9), idsToBeDeleted) +} diff --git a/internal/store/sync_test.go b/internal/store/sync_test.go new file mode 100644 index 00000000..2f7fae8f --- /dev/null +++ b/internal/store/sync_test.go @@ -0,0 +1,509 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "sort" + "strconv" + "testing" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockLister struct { + err error + messageIDs []string +} + +func (m *mockLister) ListMessages(filter *pmapi.MessagesFilter) (msgs []*pmapi.Message, total int, err error) { + if m.err != nil { + return nil, 0, m.err + } + skipByID := true + skipByPaging := filter.PageSize * filter.Page + for idx := 0; idx < len(m.messageIDs); idx++ { + var messageID string + if !*filter.Desc { + messageID = m.messageIDs[idx] + if filter.BeginID == "" || messageID == filter.BeginID { + skipByID = false + } + } else { + messageID = m.messageIDs[len(m.messageIDs)-1-idx] + if filter.EndID == "" || messageID == filter.EndID { + skipByID = false + } + } + if skipByID { + continue + } + skipByPaging-- + if skipByPaging > 0 { + continue + } + msgs = append(msgs, &pmapi.Message{ + ID: messageID, + }) + if len(msgs) == filter.PageSize || len(msgs) == filter.Limit { + break + } + if !*filter.Desc { + if messageID == filter.EndID { + break + } + } else { + if messageID == filter.BeginID { + break + } + } + } + return msgs, len(m.messageIDs), nil +} + +type mockStoreSynchronizer struct { + allMessageIDs []string + errCreateOrUpdateMessagesEvent error + createdMessageIDsByBatch [][]string +} + +func (m *mockStoreSynchronizer) getAllMessageIDs() ([]string, error) { + return m.allMessageIDs, nil +} + +func (m *mockStoreSynchronizer) createOrUpdateMessagesEvent(messages []*pmapi.Message) error { + if m.errCreateOrUpdateMessagesEvent != nil { + return m.errCreateOrUpdateMessagesEvent + } + createdMessageIDs := []string{} + for _, message := range messages { + createdMessageIDs = append(createdMessageIDs, message.ID) + } + m.createdMessageIDsByBatch = append(m.createdMessageIDsByBatch, createdMessageIDs) + return nil +} + +func (m *mockStoreSynchronizer) deleteMessagesEvent([]string) error { + return nil +} + +func (m *mockStoreSynchronizer) saveSyncState(finishTime int64, idRanges []*syncIDRange, idsToBeDeleted []string) { +} + +func newTestSyncState(store storeSynchronizer, splitIDs ...string) *syncState { + syncState := newSyncState(store, 0, []*syncIDRange{}, []string{}) + syncState.initIDRanges() + for _, splitID := range splitIDs { + syncState.addIDRange(splitID) + } + return syncState +} + +func generateIDs(start, stop int) []string { + ids := []string{} + for x := start; x <= stop; x++ { + ids = append(ids, strconv.Itoa(x)) + } + return ids +} + +func generateIDsR(start, stop int) []string { + ids := []string{} + for x := start; x >= stop; x-- { + ids = append(ids, strconv.Itoa(x)) + } + return ids +} + +// Tests + +func TestSyncAllMail(t *testing.T) { //nolint[funlen] + m, clear := initMocks(t) + defer clear() + + numberOfMessages := 10000 + + api := &mockLister{ + messageIDs: generateIDs(1, numberOfMessages), + } + + tests := []struct { + name string + idRanges []*syncIDRange + idsToBeDeleted []string + wantUpdatedIDs []string + wantNotUpdatedIDs []string + }{ + { + "full sync", + []*syncIDRange{}, + []string{}, + api.messageIDs, + []string{}, + }, + { + "continue with interrupted sync", + []*syncIDRange{ + {StartID: "2000", StopID: "2100"}, + {StartID: "4000", StopID: "4200"}, + {StartID: "9500", StopID: ""}, + }, + mergeArrays(generateIDs(2000, 2100), generateIDs(4000, 4200), generateIDs(9500, 10010)), + mergeArrays(generateIDs(2000, 2100), generateIDs(4000, 4200), generateIDs(9500, 10000)), + mergeArrays(generateIDs(1, 1999), generateIDs(2101, 3999), generateIDs(4201, 9459)), + }, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + store := &mockStoreSynchronizer{ + allMessageIDs: generateIDs(1, numberOfMessages+10), + } + syncState := newSyncState(store, 0, tc.idRanges, tc.idsToBeDeleted) + + err := syncAllMail(m.panicHandler, store, api, syncState) + require.Nil(t, err) + + // Check all messages were created or updated. + updateMessageIDsMap := map[string]bool{} + for _, messageIDs := range store.createdMessageIDsByBatch { + for _, messageID := range messageIDs { + updateMessageIDsMap[messageID] = true + } + } + for _, messageID := range tc.wantUpdatedIDs { + assert.True(t, updateMessageIDsMap[messageID], "Message %s was not created/updated, but should", messageID) + } + for _, messageID := range tc.wantNotUpdatedIDs { + assert.False(t, updateMessageIDsMap[messageID], "Message %s was created/updated, but shouldn't", messageID) + } + + // Check all messages were deleted. + idsToBeDeleted := syncState.getIDsToBeDeleted() + sort.Strings(idsToBeDeleted) + assert.Equal(t, generateIDs(numberOfMessages+1, numberOfMessages+10), idsToBeDeleted) + }) + } +} + +func mergeArrays(arrays ...[]string) []string { + result := []string{} + for _, array := range arrays { + result = append(result, array...) + } + return result +} + +func TestSyncAllMail_FailedListing(t *testing.T) { + m, clear := initMocks(t) + defer clear() + + numberOfMessages := 10000 + + store := &mockStoreSynchronizer{ + allMessageIDs: generateIDs(1, numberOfMessages+10), + } + api := &mockLister{ + err: errors.New("error"), + messageIDs: generateIDs(1, numberOfMessages), + } + syncState := newTestSyncState(store) + + err := syncAllMail(m.panicHandler, store, api, syncState) + require.EqualError(t, err, "failed to sync group: failed to list messages: error") +} + +func TestSyncAllMail_FailedCreateOrUpdateMessage(t *testing.T) { + m, clear := initMocks(t) + defer clear() + + numberOfMessages := 10000 + + store := &mockStoreSynchronizer{ + errCreateOrUpdateMessagesEvent: errors.New("error"), + allMessageIDs: generateIDs(1, numberOfMessages+10), + } + api := &mockLister{ + messageIDs: generateIDs(1, numberOfMessages), + } + syncState := newTestSyncState(store) + + err := syncAllMail(m.panicHandler, store, api, syncState) + require.EqualError(t, err, "failed to sync group: failed to create or update messages: error") +} + +func TestFindIDRanges(t *testing.T) { //nolint[funlen] + store := &mockStoreSynchronizer{} + syncState := newTestSyncState(store) + + tests := []struct { + name string + messageIDs []string + wantBatches [][]string + }{ + { + "1k messages - 1 batch", + generateIDs(1, 1000), + [][]string{ + {"", ""}, + }, + }, + { + "1k messages not starting at 1", + generateIDs(1000, 2000), + [][]string{ + {"", ""}, + }, + }, + { + "2k messages - 2 batches", + generateIDs(1, 2000), + [][]string{ + {"", "1050"}, + {"1050", ""}, + }, + }, + { + "4k messages - 3 batches", + generateIDs(1, 4000), + [][]string{ + {"", "1350"}, + {"1350", "2700"}, + {"2700", ""}, + }, + }, + { + "5k messages - 4 batches", + generateIDs(1, 5000), + [][]string{ + {"", "1350"}, + {"1350", "2700"}, + {"2700", "4050"}, + {"4050", ""}, + }, + }, + { + "10k messages - 5 batches", + generateIDs(1, 10000), + [][]string{ + {"", "2100"}, + {"2100", "4200"}, + {"4200", "6300"}, + {"6300", "8400"}, + {"8400", ""}, + }, + }, + { + "150k messages - 5 batches", + generateIDs(1, 150000), + [][]string{ + {"", "30000"}, + {"30000", "60000"}, + {"60000", "90000"}, + {"90000", "120000"}, + {"120000", ""}, + }, + }, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + api := &mockLister{ + messageIDs: tc.messageIDs, + } + + err := findIDRanges(pmapi.AllMailLabel, api, syncState) + + require.Nil(t, err) + require.Equal(t, len(tc.wantBatches), len(syncState.idRanges)) + for idx, idRange := range syncState.idRanges { + want := tc.wantBatches[idx] + assert.Equal(t, want[0], idRange.StartID, "Start ID for IDs range %d does not match", idx) + assert.Equal(t, want[1], idRange.StopID, "Stop ID for IDs range %d does not match", idx) + } + }) + } +} + +func TestFindIDRanges_FailedListing(t *testing.T) { + store := &mockStoreSynchronizer{} + api := &mockLister{ + err: errors.New("error"), + } + + syncState := newTestSyncState(store) + + err := findIDRanges(pmapi.AllMailLabel, api, syncState) + require.EqualError(t, err, "failed to get first ID and count: failed to list messages: error") +} + +func TestGetSplitIDAndCount(t *testing.T) { //nolint[funlen] + tests := []struct { + name string + err error + messageIDs []string + page int + wantID string + wantTotal int + wantErr string + }{ + { + "1000 messages, first page", + nil, + generateIDs(1, 1000), + 0, + "1", + 1000, + "", + }, + { + "1000 messages, 5th page", + nil, + generateIDs(1, 1000), + 4, + "600", + 1000, + "", + }, + { + "one message, first page", + nil, + []string{"1"}, + 0, + "1", + 1, + "", + }, + { + "no message, first page", + nil, + []string{}, + 0, + "", + 0, + "", + }, + { + "listing error", + errors.New("error"), + generateIDs(1, 1000), + 0, + "", + 0, + "failed to list messages: error", + }, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + api := &mockLister{ + err: tc.err, + messageIDs: tc.messageIDs, + } + + id, total, err := getSplitIDAndCount(pmapi.AllMailLabel, api, tc.page) + + if tc.wantErr == "" { + require.Nil(t, err) + } else { + require.EqualError(t, err, tc.wantErr) + } + assert.Equal(t, tc.wantID, id) + assert.Equal(t, tc.wantTotal, total) + }) + } +} + +func TestSyncBatch(t *testing.T) { + tests := []struct { + name string + batchIdx int + wantCreatedMessageIDsByBatch [][]string + }{ + { + "first-batch", + 0, + [][]string{generateIDsR(200, 51), generateIDsR(51, 1)}, + }, + { + "second-batch", + 1, + [][]string{generateIDsR(400, 251), generateIDsR(251, 200)}, + }, + { + "third-batch", + 2, + [][]string{generateIDsR(600, 451), generateIDsR(451, 400)}, + }, + { + "fourth-batch", + 3, + [][]string{generateIDsR(800, 651), generateIDsR(651, 600)}, + }, + { + "fifth-batch", + 4, + [][]string{generateIDsR(1000, 851), generateIDsR(851, 800)}, + }, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + store := &mockStoreSynchronizer{} + api := &mockLister{ + messageIDs: generateIDs(1, 1000), + } + + err := testSyncBatch(t, store, api, tc.batchIdx, "200", "400", "600", "800") + require.Nil(t, err) + require.Equal(t, tc.wantCreatedMessageIDsByBatch, store.createdMessageIDsByBatch) + }) + } +} + +func TestSyncBatch_FailedListing(t *testing.T) { + store := &mockStoreSynchronizer{} + api := &mockLister{ + err: errors.New("error"), + messageIDs: generateIDs(1, 1000), + } + + err := testSyncBatch(t, store, api, 0) + require.EqualError(t, err, "failed to list messages: error") +} + +func TestSyncBatch_FailedCreateOrUpdateMessage(t *testing.T) { + store := &mockStoreSynchronizer{ + errCreateOrUpdateMessagesEvent: errors.New("error"), + } + api := &mockLister{ + messageIDs: generateIDs(1, 1000), + } + + err := testSyncBatch(t, store, api, 0) + require.EqualError(t, err, "failed to create or update messages: error") +} + +func testSyncBatch(t *testing.T, store storeSynchronizer, api messageLister, rangeIdx int, splitIDs ...string) error { //nolint[unparam] + syncState := newTestSyncState(store, splitIDs...) + idRange := syncState.idRanges[rangeIdx] + shouldStop := 0 + return syncBatch(pmapi.AllMailLabel, store, api, syncState, idRange, &shouldStop) +} diff --git a/internal/store/types.go b/internal/store/types.go new file mode 100644 index 00000000..9319a651 --- /dev/null +++ b/internal/store/types.go @@ -0,0 +1,69 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "io" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" +) + +type PanicHandler interface { + HandlePanic() +} + +// PMAPIProvider is subset of pmapi.Client for use by the Store. +type PMAPIProvider interface { + CurrentUser() (*pmapi.User, error) + Addresses() pmapi.AddressList + + GetEvent(eventID string) (*pmapi.Event, error) + + CountMessages(addressID string) ([]*pmapi.MessagesCount, error) + ListMessages(filter *pmapi.MessagesFilter) ([]*pmapi.Message, int, error) + GetMessage(apiID string) (*pmapi.Message, error) + Import([]*pmapi.ImportMsgReq) ([]*pmapi.ImportMsgRes, error) + DeleteMessages(apiIDs []string) error + LabelMessages(apiIDs []string, labelID string) error + UnlabelMessages(apiIDs []string, labelID string) error + MarkMessagesRead(apiIDs []string) error + MarkMessagesUnread(apiIDs []string) error + + CreateDraft(m *pmapi.Message, parent string, action int) (created *pmapi.Message, err error) + CreateAttachment(att *pmapi.Attachment, r io.Reader, sig io.Reader) (created *pmapi.Attachment, err error) + SendMessage(messageID string, req *pmapi.SendMessageReq) (sent, parent *pmapi.Message, err error) + + ListLabels() ([]*pmapi.Label, error) + CreateLabel(label *pmapi.Label) (*pmapi.Label, error) + UpdateLabel(label *pmapi.Label) (*pmapi.Label, error) + DeleteLabel(labelID string) error + EmptyFolder(labelID string, addressID string) error +} + +// BridgeUser is subset of bridge.User for use by the Store. +type BridgeUser interface { + ID() string + GetAddressID(address string) (string, error) + IsConnected() bool + IsCombinedAddressMode() bool + GetPrimaryAddress() string + GetStoreAddresses() []string + UpdateUser() error + CloseConnection(string) + Logout() error +} diff --git a/internal/store/ulimit.go b/internal/store/ulimit.go new file mode 100644 index 00000000..9c74702c --- /dev/null +++ b/internal/store/ulimit.go @@ -0,0 +1,64 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "fmt" + "os" + "os/exec" + "runtime" + "strconv" + "strings" +) + +func uLimit() int { + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { + return 0 + } + out, err := exec.Command("bash", "-c", "ulimit -n").Output() + if err != nil { + log.Print(err) + return 0 + } + outStr := strings.Trim(string(out), " \n") + num, err := strconv.Atoi(outStr) + if err != nil { + log.Print(err) + return 0 + } + return num +} + +func isFdCloseToULimit() bool { + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { + return false + } + + pid := fmt.Sprint(os.Getpid()) + out, err := exec.Command("lsof", "-p", pid).Output() //nolint[gosec] + if err != nil { + log.Warn("isFdCloseToULimit: ", err) + return false + } + lines := strings.Split(string(out), "\n") + + fd := len(lines) - 1 + ulimit := uLimit() + log.Info("File descriptor check: num goroutines ", runtime.NumGoroutine(), " fd ", fd, " ulimit ", ulimit) + return fd >= int(0.95*float64(ulimit)) +} diff --git a/internal/store/user.go b/internal/store/user.go new file mode 100644 index 00000000..6bbf90c9 --- /dev/null +++ b/internal/store/user.go @@ -0,0 +1,41 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +// UserID returns user ID. +func (store *Store) UserID() string { + return store.user.ID() +} + +// GetSpace returns used and total space in bytes. +func (store *Store) GetSpace() (usedSpace, maxSpace uint, err error) { + apiUser, err := store.api.CurrentUser() + if err != nil { + return 0, 0, err + } + return uint(apiUser.UsedSpace), uint(apiUser.MaxSpace), nil +} + +// GetMaxUpload returns max size of attachment in bytes. +func (store *Store) GetMaxUpload() (uint, error) { + apiUser, err := store.api.CurrentUser() + if err != nil { + return 0, err + } + return uint(apiUser.MaxUpload), nil +} diff --git a/internal/store/user_address.go b/internal/store/user_address.go new file mode 100644 index 00000000..811e3e4b --- /dev/null +++ b/internal/store/user_address.go @@ -0,0 +1,216 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "encoding/json" + "fmt" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/pkg/errors" + bolt "go.etcd.io/bbolt" +) + +// GetAddress returns the store address by given ID. +func (store *Store) GetAddress(addressID string) (*Address, error) { + store.lock.RLock() + defer store.lock.RUnlock() + + storeAddress, ok := store.addresses[addressID] + if !ok { + return nil, fmt.Errorf("addressID %v does not exist", addressID) + } + + return storeAddress, nil +} + +// RebuildMailboxes truncates all mailbox buckets and recreates them from the metadata bucket again. +func (store *Store) RebuildMailboxes() (err error) { + store.lock.Lock() + defer store.lock.Unlock() + + log.WithField("user", store.UserID()).Trace("Truncating mailboxes") + + if err = store.truncateMailboxesBucket(); err != nil { + log.WithError(err).Error("Could not truncate mailboxes bucket") + return + } + + if err = store.truncateAddressInfoBucket(); err != nil { + log.WithError(err).Error("Could not truncate address info bucket") + return + } + + if err = store.init(false); err != nil { + log.WithError(err).Error("Could not init store") + return + } + + if err := store.increaseMailboxesVersion(); err != nil { + log.WithError(err).Error("Could not increase structure version") + // Do not return here. The truncation was already done and mode + // was changed in DB so we need to sync so that users start to see + // messages and not block other operations. + } + + log.WithField("user", store.UserID()).Trace("Rebuilding mailboxes") + store.triggerSync() + return nil +} + +// createOrDeleteAddressesEvent creates address objects in the store for each necessary address +// and deletes any address objects that shouldn't be there. +// It doesn't do anything to addresses that are rightfully there. +// It should only be called from the event loop. +func (store *Store) createOrDeleteAddressesEvent() (err error) { + labels, err := store.initCounts() + if err != nil { + return errors.Wrap(err, "failed to initialise label counts") + } + + addrInfo, err := store.GetAddressInfo() + if err != nil { + return errors.Wrap(err, "failed to get addresses and address IDs") + } + + // We need at least one address to continue. + if len(addrInfo) < 1 { + return errors.New("no addresses to initialise") + } + + // If in combined mode, we only need the user's primary address. + if store.addressMode == combinedMode { + addrInfo = addrInfo[:1] + } + + // Go through all addresses that *should* be there. + for _, addr := range addrInfo { + if _, ok := store.addresses[addr.AddressID]; ok { + continue + } + + // This address is missing so we create it. + if err = store.addAddress(addr.Address, addr.AddressID, labels); err != nil { + return errors.Wrap(err, "failed to add address to store") + } + } + + // Go through all addresses that *should not* be there. + for _, addr := range store.addresses { + belongs := false + + for _, a := range addrInfo { + if addr.addressID == a.AddressID { + belongs = true + break + } + } + + if belongs { + continue + } + + delete(store.addresses, addr.addressID) + } + + return err +} + +// truncateAddressInfoBucket removes the address info bucket. +func (store *Store) truncateAddressInfoBucket() (err error) { + log.Trace("Truncating address info bucket") + + tx := func(tx *bolt.Tx) (err error) { + if err = tx.DeleteBucket(addressInfoBucket); err != nil { + return + } + + if _, err = tx.CreateBucketIfNotExists(addressInfoBucket); err != nil { + return + } + + return + } + + return store.db.Update(tx) +} + +// truncateMailboxesBucket removes the mailboxes bucket. +func (store *Store) truncateMailboxesBucket() (err error) { + log.Trace("Truncating mailboxes bucket") + + store.addresses = nil + + tx := func(tx *bolt.Tx) (err error) { + mbs := tx.Bucket(mailboxesBucket) + + return mbs.ForEach(func(addrIDMailbox, _ []byte) (err error) { + addr := mbs.Bucket(addrIDMailbox) + + if err = addr.DeleteBucket(imapIDsBucket); err != nil { + return + } + + if _, err = addr.CreateBucketIfNotExists(imapIDsBucket); err != nil { + return + } + + if err = addr.DeleteBucket(apiIDsBucket); err != nil { + return + } + + if _, err = addr.CreateBucketIfNotExists(apiIDsBucket); err != nil { + return + } + + return + }) + } + + return store.db.Update(tx) +} + +// initMailboxesBucket recreates the mailboxes bucket from the metadata bucket. +func (store *Store) initMailboxesBucket() error { //nolint[unused] + i := 0 + tx := func(tx *bolt.Tx) error { + return tx.Bucket(metadataBucket).ForEach(func(k, v []byte) error { + msg := &pmapi.Message{} + + if err := json.Unmarshal(v, msg); err != nil { + return err + } + + for _, a := range store.addresses { + if err := a.txCreateOrUpdateMessages(tx, []*pmapi.Message{msg}); err != nil { + return err + } + } + + i++ + if i%100 == 0 { + store.log.WithField("i", i). + Trace("Init mboxes heartbeat") + } + + return nil + }) + } + + return store.db.Update(tx) +} diff --git a/internal/store/user_address_info.go b/internal/store/user_address_info.go new file mode 100644 index 00000000..6abde2be --- /dev/null +++ b/internal/store/user_address_info.go @@ -0,0 +1,158 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "encoding/json" + "strings" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/pkg/errors" + bolt "go.etcd.io/bbolt" +) + +// AddressInfo is the format of the data held in the addresses bucket in the store. +// It allows us to easily keep an address and its ID together and serialisation/deserialisation to []byte. +type AddressInfo struct { + Address, AddressID string +} + +// GetAddressID returns the ID of the given address. +func (store *Store) GetAddressID(addr string) (id string, err error) { + addrs, err := store.GetAddressInfo() + if err != nil { + return + } + + for _, addrInfo := range addrs { + if strings.EqualFold(addrInfo.Address, addr) { + id = addrInfo.AddressID + return + } + } + + err = errors.New("no such address") + + return +} + +// GetAddressInfo returns information about all addresses owned by the user. +// The first element is the user's primary address and the rest (if present) are aliases. +// It tries to source the information from the store but if the store doesn't yet have it, it +// fetches it from the API and caches it for later. +func (store *Store) GetAddressInfo() (addrs []AddressInfo, err error) { + if addrs, err = store.getAddressInfoFromStore(); err == nil && len(addrs) > 0 { + return + } + + // Store does not have address info yet, need to build it first from API. + addressList := store.api.Addresses() + if addressList == nil { + err = errors.New("addresses unavailable") + store.log.WithError(err).Error("Could not get user addresses from API") + return + } + + if err = store.createOrUpdateAddressInfo(addressList); err != nil { + store.log.WithError(err).Warn("Could not update address IDs in store") + return + } + + return store.getAddressInfoFromStore() +} + +// getAddressIDsByAddressFromStore returns a map from address to addressID for each address owned by the user. +func (store *Store) getAddressInfoFromStore() (addrs []AddressInfo, err error) { + store.log.Debug("Retrieving address info from store") + + tx := func(tx *bolt.Tx) (err error) { + c := tx.Bucket(addressInfoBucket).Cursor() + for index, addrInfoBytes := c.First(); index != nil; index, addrInfoBytes = c.Next() { + var addrInfo AddressInfo + + if err = json.Unmarshal(addrInfoBytes, &addrInfo); err != nil { + store.log.WithError(err).Error("Could not unmarshal address and addressID") + return + } + + addrs = append(addrs, addrInfo) + } + + return + } + + err = store.db.View(tx) + + return +} + +// createOrUpdateAddressInfo updates the store address/addressID bucket to match the given address list. +// The address list supplied is assumed to contain active emails in any order. +// It firstly (and stupidly) deletes the bucket of addresses and then fills it with up to date info. +// This is because a user might delete an address and we don't want old addresses lying around (and finding the +// specific ones to delete is likely not much more efficient than just rebuilding from scratch). +func (store *Store) createOrUpdateAddressInfo(addressList pmapi.AddressList) (err error) { + tx := func(tx *bolt.Tx) error { + if err := tx.DeleteBucket(addressInfoBucket); err != nil { + store.log.WithError(err).Error("Could not delete addressIDs bucket") + return err + } + + if _, err := tx.CreateBucketIfNotExists(addressInfoBucket); err != nil { + store.log.WithError(err).Error("Could not recreate addressIDs bucket") + return err + } + + addrsBucket := tx.Bucket(addressInfoBucket) + + for index, address := range filterAddresses(addressList) { + ib := itob(uint32(index)) + + info, err := json.Marshal(AddressInfo{ + Address: address.Email, + AddressID: address.ID, + }) + if err != nil { + store.log.WithError(err).Error("Could not marshal address and addressID") + return err + } + + if err := addrsBucket.Put(ib, info); err != nil { + store.log.WithError(err).Error("Could not put address and addressID into store") + return err + } + } + + return nil + } + + return store.db.Update(tx) +} + +// filterAddresses filters out inactive addresses and ensures the original address is listed first. +func filterAddresses(addressList pmapi.AddressList) (filteredList pmapi.AddressList) { + for _, address := range addressList { + if address.Receive != pmapi.CanReceive { + continue + } + + filteredList = append(filteredList, address) + } + + return +} diff --git a/internal/store/user_mailbox.go b/internal/store/user_mailbox.go new file mode 100644 index 00000000..0e341f40 --- /dev/null +++ b/internal/store/user_mailbox.go @@ -0,0 +1,229 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "fmt" + "strings" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/pkg/errors" +) + +// createMailbox creates the mailbox via the API. +// The store mailbox is created later by processing an event. +func (store *Store) createMailbox(name string) error { + defer store.eventLoop.pollNow() + + log.WithField("name", name).Debug("Creating mailbox") + + if store.hasMailbox(name) { + return fmt.Errorf("mailbox %v already exists", name) + } + + color := store.leastUsedColor() + + var exclusive int + switch { + case strings.HasPrefix(name, UserLabelsPrefix): + name = strings.TrimPrefix(name, UserLabelsPrefix) + exclusive = 0 + case strings.HasPrefix(name, UserFoldersPrefix): + name = strings.TrimPrefix(name, UserFoldersPrefix) + exclusive = 1 + default: + // Ideally we would throw an error here, but then Outlook for + // macOS keeps trying to make an IMAP Drafts folder and popping + // up the error to the user. + store.log.WithField("name", name). + Warn("Ignoring creation of new mailbox in IMAP root") + return nil + } + + _, err := store.api.CreateLabel(&pmapi.Label{ + Name: name, + Color: color, + Exclusive: exclusive, + Type: pmapi.LabelTypeMailbox, + }) + return err +} + +// allAddressesHaveMailbox returns whether each address has a mailbox with the given labelID. +func (store *Store) allAddressesHaveMailbox(labelID string) bool { + store.lock.RLock() + defer store.lock.RUnlock() + + for _, a := range store.addresses { + addressHasMailbox := false + for _, m := range a.mailboxes { + if m.labelID == labelID { + addressHasMailbox = true + break + } + } + if !addressHasMailbox { + return false + } + } + return true +} + +// hasMailbox returns whether there is at least one address which has a mailbox with the given name. +func (store *Store) hasMailbox(name string) bool { + mailbox, _ := store.getMailbox(name) + return mailbox != nil +} + +// getMailbox returns the first mailbox with the given name. +func (store *Store) getMailbox(name string) (*Mailbox, error) { + store.lock.RLock() + defer store.lock.RUnlock() + + for _, a := range store.addresses { + for _, m := range a.mailboxes { + if m.labelName == name { + return m, nil + } + } + } + return nil, fmt.Errorf("mailbox %s does not exist", name) +} + +// leastUsedColor returns the least used color to be used for a newly created folder or label. +func (store *Store) leastUsedColor() string { + store.lock.RLock() + defer store.lock.RUnlock() + + usage := map[string]int{} + for _, a := range store.addresses { + for _, m := range a.mailboxes { + if m.color != "" { + usage[m.color]++ + } + } + } + + leastUsed := pmapi.LabelColors[0] + for _, color := range pmapi.LabelColors { + if usage[leastUsed] > usage[color] { + leastUsed = color + } + } + return leastUsed +} + +// updateMailbox updates the mailbox via the API. +// The store mailbox is updated later by processing an event. +func (store *Store) updateMailbox(labelID, newName, color string) error { + defer store.eventLoop.pollNow() + + _, err := store.api.UpdateLabel(&pmapi.Label{ + ID: labelID, + Name: newName, + Color: color, + }) + return err +} + +// deleteMailbox deletes the mailbox via the API. +// The store mailbox is deleted later by processing an event. +func (store *Store) deleteMailbox(labelID, addressID string) error { + defer store.eventLoop.pollNow() + + if pmapi.IsSystemLabel(labelID) { + var err error + switch labelID { + case pmapi.SpamLabel: + err = store.api.EmptyFolder(pmapi.SpamLabel, addressID) + case pmapi.TrashLabel: + err = store.api.EmptyFolder(pmapi.TrashLabel, addressID) + default: + err = fmt.Errorf("cannot empty mailbox %v", labelID) + } + return err + } + return store.api.DeleteLabel(labelID) +} + +func (store *Store) createLabelsIfMissing(affectedLabelIDs map[string]bool) error { + newLabelIDs := []string{} + for labelID := range affectedLabelIDs { + if pmapi.IsSystemLabel(labelID) || store.allAddressesHaveMailbox(labelID) { + continue + } + newLabelIDs = append(newLabelIDs, labelID) + } + if len(newLabelIDs) == 0 { + return nil + } + + labels, err := store.api.ListLabels() + if err != nil { + return err + } + for _, newLabelID := range newLabelIDs { + for _, label := range labels { + if label.ID != newLabelID { + continue + } + if err := store.createOrUpdateMailboxEvent(label); err != nil { + return err + } + } + } + return nil +} + +// createOrUpdateMailboxEvent creates or updates the mailbox in the store. +// This is called from the event loop. +func (store *Store) createOrUpdateMailboxEvent(label *pmapi.Label) error { + store.lock.Lock() + defer store.lock.Unlock() + + if label.Type != pmapi.LabelTypeMailbox { + return nil + } + + if err := store.createOrUpdateMailboxCountsBuckets([]*pmapi.Label{label}); err != nil { + return errors.Wrap(err, "cannot update counts") + } + + for _, a := range store.addresses { + if err := a.createOrUpdateMailboxEvent(label); err != nil { + return err + } + } + return nil +} + +// deleteMailboxEvent deletes the mailbox in the store. +// This is called from the event loop. +func (store *Store) deleteMailboxEvent(labelID string) error { + store.lock.Lock() + defer store.lock.Unlock() + + _ = store.removeMailboxCount(labelID) + + for _, a := range store.addresses { + if err := a.deleteMailboxEvent(labelID); err != nil { + return err + } + } + return nil +} diff --git a/internal/store/user_message.go b/internal/store/user_message.go new file mode 100644 index 00000000..ee4465a5 --- /dev/null +++ b/internal/store/user_message.go @@ -0,0 +1,329 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "bytes" + "encoding/json" + "io" + "io/ioutil" + "net/mail" + "net/textproto" + "strings" + + pmcrypto "github.com/ProtonMail/gopenpgp/crypto" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + bolt "go.etcd.io/bbolt" +) + +// CreateDraft creates draft with attachments. +// If `attachedPublicKey` is passed, it's added to attachments. +// Both draft and attachments are encrypted with passed `kr` key. +func (store *Store) CreateDraft( + kr *pmcrypto.KeyRing, + message *pmapi.Message, + attachmentReaders []io.Reader, + attachedPublicKey, + attachedPublicKeyName string, + parentID string) (*pmapi.Message, []*pmapi.Attachment, error) { + defer store.eventLoop.pollNow() + + // Since this is a draft, we don't need to sign it. + if err := message.Encrypt(kr, nil); err != nil { + return nil, nil, errors.Wrap(err, "failed to encrypt draft") + } + + attachments := message.Attachments + message.Attachments = nil + + draftAction := store.getDraftAction(message) + draft, err := store.api.CreateDraft(message, parentID, draftAction) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to create draft") + } + + if attachedPublicKey != "" { + attachmentReaders = append(attachmentReaders, strings.NewReader(attachedPublicKey)) + publicKeyAttachment := &pmapi.Attachment{ + Name: attachedPublicKeyName + ".asc", + MIMEType: "application/pgp-key", + Header: textproto.MIMEHeader{}, + } + attachments = append(attachments, publicKeyAttachment) + } + + for idx, attachment := range attachments { + attachment.MessageID = draft.ID + attachmentBody, _ := ioutil.ReadAll(attachmentReaders[idx]) + + createdAttachment, err := store.createAttachment(kr, attachment, attachmentBody) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to create attachment for draft") + } + + attachments[idx] = createdAttachment + } + + return draft, attachments, nil +} + +func (store *Store) getDraftAction(message *pmapi.Message) int { + // If not a reply, must be a forward. + if len(message.Header["In-Reply-To"]) == 0 { + return pmapi.DraftActionForward + } + return pmapi.DraftActionReply +} + +func (store *Store) createAttachment(kr *pmcrypto.KeyRing, attachment *pmapi.Attachment, attachmentBody []byte) (*pmapi.Attachment, error) { + r := bytes.NewReader(attachmentBody) + sigReader, err := attachment.DetachedSign(kr, r) + if err != nil { + return nil, errors.Wrap(err, "failed to sign attachment") + } + + r = bytes.NewReader(attachmentBody) + encReader, err := attachment.Encrypt(kr, r) + if err != nil { + return nil, errors.Wrap(err, "failed to encrypt attachment") + } + + createdAttachment, err := store.api.CreateAttachment(attachment, encReader, sigReader) + if err != nil { + return nil, errors.Wrap(err, "failed to create attachment") + } + + return createdAttachment, nil +} + +// SendMessage sends the message. +func (store *Store) SendMessage(messageID string, req *pmapi.SendMessageReq) error { + defer store.eventLoop.pollNow() + _, _, err := store.api.SendMessage(messageID, req) + return err +} + +// getAllMessageIDs returns all API IDs of messages in the local database. +func (store *Store) getAllMessageIDs() (apiIDs []string, err error) { + err = store.db.View(func(tx *bolt.Tx) error { + b := tx.Bucket(metadataBucket) + return b.ForEach(func(k, v []byte) error { + apiIDs = append(apiIDs, string(k)) + return nil + }) + }) + return +} + +// getMessageFromDB returns pmapi struct of message by API ID. +func (store *Store) getMessageFromDB(apiID string) (msg *pmapi.Message, err error) { + err = store.db.View(func(tx *bolt.Tx) error { + msg, err = store.txGetMessage(tx, apiID) + return err + }) + + return +} + +// fetchMessage returns pmapi struct of message by API ID. If the requested +// message is not in the database, it will try to fetch it from the server. +// NOTE: Do not update the database here to prevent issues (extreme edge case). +// The database will be updated by the event loop anyway. +func (store *Store) fetchMessage(apiID string) (msg *pmapi.Message, err error) { + if msg, err = store.api.GetMessage(apiID); err != nil { + if err.Error() == "Message does not exist" { + return nil, ErrNoSuchAPIID + } + } + return +} + +func (store *Store) txGetMessage(tx *bolt.Tx, apiID string) (*pmapi.Message, error) { + b := tx.Bucket(metadataBucket) + + msgb := b.Get([]byte(apiID)) + if msgb == nil { + return nil, ErrNoSuchAPIID + } + msg := &pmapi.Message{} + if err := json.Unmarshal(msgb, msg); err != nil { + return nil, err + } + return msg, nil +} + +func (store *Store) txPutMessage(metaBucket *bolt.Bucket, onlyMeta *pmapi.Message) error { + b, err := json.Marshal(onlyMeta) + if err != nil { + return errors.Wrap(err, "cannot marshall metadata") + } + err = metaBucket.Put([]byte(onlyMeta.ID), b) + if err != nil { + return errors.Wrap(err, "cannot add to metadata bucket") + } + return nil +} + +// createOrUpdateMessageEvent is helper to create only one message with +// createOrUpdateMessagesEvent. +func (store *Store) createOrUpdateMessageEvent(msg *pmapi.Message) error { + return store.createOrUpdateMessagesEvent([]*pmapi.Message{msg}) +} + +// createOrUpdateMessagesEvent tries to create or update messages in database. +// This function is optimised for insertion of many messages at once. +// It calls createLabelsIfMissing if needed. +func (store *Store) createOrUpdateMessagesEvent(msgs []*pmapi.Message) error { //nolint[funlen] + store.log.WithField("msgs", msgs).Trace("Creating or updating messages in the store") + + // Strip non meta first to reduce memory (no need to keep all old msg ID data during update). + err := store.db.View(func(tx *bolt.Tx) error { + b := tx.Bucket(metadataBucket) + for _, msg := range msgs { + clearNonMetadata(msg) + txUpdateMetadaFromDB(b, msg, store.log) + } + return nil + }) + if err != nil { + return err + } + + affectedLabels := map[string]bool{} + for _, m := range msgs { + for _, l := range m.LabelIDs { + affectedLabels[l] = true + } + } + if err = store.createLabelsIfMissing(affectedLabels); err != nil { + return err + } + + // Updating metadata and mailboxes is not atomic, but this is OK. + // The worst case scenario is we have metadata but not updated mailboxes + // which is OK as without information in mailboxes IMAP we will never ask + // for metadata. Also, when doing the operation again, it will simply + // update the metadata. + // The reason to split is efficiency--it's more memory efficient. + + // Update metadata. + err = store.db.Update(func(tx *bolt.Tx) error { + metaBucket := tx.Bucket(metadataBucket) + for _, msg := range msgs { + err := store.txPutMessage(metaBucket, msg) + if err != nil { + return err + } + } + return nil + }) + if err != nil { + return err + } + + // Update mailboxes. + err = store.db.Update(func(tx *bolt.Tx) error { + for _, a := range store.addresses { + if err := a.txCreateOrUpdateMessages(tx, msgs); err != nil { + store.log.WithError(err).Error("cannot update maiboxes") + return errors.Wrap(err, "cannot add to mailboxes bucket") + } + } + return nil + }) + if err != nil { + return err + } + + return nil +} + +// clearNonMetadata to not allow to store decrypted or encrypted data i.e. body +// and attachments. +func clearNonMetadata(onlyMeta *pmapi.Message) { + onlyMeta.Body = "" + onlyMeta.Attachments = nil +} + +// txUpdateMetadaFromDB changes the the onlyMeta data. +// If there is stored message in metaBucket the size, header and MIMEType are +// not changed if already set. To change these: +// * size must be updated by Message.SetSize +// * contentType and header must be updated by Message.SetContentTypeAndHeader +func txUpdateMetadaFromDB(metaBucket *bolt.Bucket, onlyMeta *pmapi.Message, log *logrus.Entry) { + // Size attribute on the server is counting encrypted data. We need to compute + // "real" size of decrypted data. Negative values will be processed during fetch. + onlyMeta.Size = -1 + + msgb := metaBucket.Get([]byte(onlyMeta.ID)) + if msgb == nil { + return + } + + // It is faster to unmarshal only the needed items. + stored := &struct { + Size int64 + Header string + MIMEType string + }{} + if err := json.Unmarshal(msgb, stored); err != nil { + log.WithError(err). + Error("Fail to unmarshal from DB, metadata will be overwritten") + return + } + + // Keep already calculated size and content type. + onlyMeta.Size = stored.Size + onlyMeta.MIMEType = stored.MIMEType + if stored.Header != "" && stored.Header != "(No Header)" { + tmpMsg, err := mail.ReadMessage( + strings.NewReader(stored.Header + "\r\n\r\n"), + ) + if err == nil { + onlyMeta.Header = tmpMsg.Header + } else { + log.WithError(err). + Error("Fail to parse, the header will be overwritten") + } + } +} + +// deleteMessageEvent is helper to delete only one message with deleteMessagesEvent. +func (store *Store) deleteMessageEvent(apiID string) error { + return store.deleteMessagesEvent([]string{apiID}) +} + +// deleteMessagesEvent deletes the message from metadata and all mailbox buckets. +func (store *Store) deleteMessagesEvent(apiIDs []string) error { + return store.db.Update(func(tx *bolt.Tx) error { + for _, apiID := range apiIDs { + if err := tx.Bucket(metadataBucket).Delete([]byte(apiID)); err != nil { + return err + } + + for _, a := range store.addresses { + if err := a.txDeleteMessage(tx, apiID); err != nil { + return err + } + } + } + return nil + }) +} diff --git a/internal/store/user_message_test.go b/internal/store/user_message_test.go new file mode 100644 index 00000000..55bd760b --- /dev/null +++ b/internal/store/user_message_test.go @@ -0,0 +1,156 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "net/mail" + "testing" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + a "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetAllMessageIDs(t *testing.T) { + m, clear := initMocks(t) + defer clear() + + m.newStoreNoEvents(true) + + insertMessage(t, m, "msg1", "Test message 1", addrID1, 0, []string{pmapi.AllMailLabel, pmapi.InboxLabel}) + insertMessage(t, m, "msg2", "Test message 2", addrID1, 0, []string{pmapi.AllMailLabel, pmapi.ArchiveLabel}) + insertMessage(t, m, "msg3", "Test message 3", addrID1, 0, []string{pmapi.AllMailLabel, pmapi.InboxLabel}) + insertMessage(t, m, "msg4", "Test message 4", addrID1, 0, []string{}) + + checkAllMessageIDs(t, m, []string{"msg1", "msg2", "msg3", "msg4"}) +} + +func TestGetMessageFromDB(t *testing.T) { + m, clear := initMocks(t) + defer clear() + + m.newStoreNoEvents(true) + insertMessage(t, m, "msg1", "Test message 1", addrID1, 0, []string{pmapi.AllMailLabel}) + + tests := []struct{ msgID, wantErr string }{ + {"msg1", ""}, + {"msg2", "no such api id"}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.msgID, func(t *testing.T) { + msg, err := m.store.getMessageFromDB(tc.msgID) + if tc.wantErr != "" { + require.EqualError(t, err, tc.wantErr) + } else { + require.Nil(t, err) + require.Equal(t, tc.msgID, msg.ID) + } + }) + } +} + +func TestCreateOrUpdateMessageMetadata(t *testing.T) { + m, clear := initMocks(t) + defer clear() + + m.newStoreNoEvents(true) + insertMessage(t, m, "msg1", "Test message 1", addrID1, 0, []string{pmapi.AllMailLabel}) + + msg, err := m.store.getMessageFromDB("msg1") + require.Nil(t, err) + + // Check non-meta and calculated data are cleared/empty. + a.Equal(t, "", msg.Body) + a.Equal(t, []*pmapi.Attachment(nil), msg.Attachments) + a.Equal(t, int64(-1), msg.Size) + a.Equal(t, "", msg.MIMEType) + a.Equal(t, mail.Header(nil), msg.Header) + + // Change the calculated data. + wantSize := int64(42) + wantMIMEType := "plain-text" + wantHeader := mail.Header{ + "Key": []string{"value"}, + } + + storeMsg, err := m.store.addresses[addrID1].mailboxes[pmapi.AllMailLabel].GetMessage("msg1") + require.Nil(t, err) + require.Nil(t, storeMsg.SetSize(wantSize)) + require.Nil(t, storeMsg.SetContentTypeAndHeader(wantMIMEType, wantHeader)) + + // Check calculated data. + msg, err = m.store.getMessageFromDB("msg1") + require.Nil(t, err) + a.Equal(t, wantSize, msg.Size) + a.Equal(t, wantMIMEType, msg.MIMEType) + a.Equal(t, wantHeader, msg.Header) + + // Check calculated data are not overridden by reinsert. + insertMessage(t, m, "msg1", "Test message 1", addrID1, 0, []string{pmapi.AllMailLabel}) + + msg, err = m.store.getMessageFromDB("msg1") + require.Nil(t, err) + a.Equal(t, wantSize, msg.Size) + a.Equal(t, wantMIMEType, msg.MIMEType) + a.Equal(t, wantHeader, msg.Header) +} + +func TestDeleteMessage(t *testing.T) { + m, clear := initMocks(t) + defer clear() + + m.newStoreNoEvents(true) + insertMessage(t, m, "msg1", "Test message 1", addrID1, 0, []string{pmapi.AllMailLabel}) + insertMessage(t, m, "msg2", "Test message 2", addrID1, 0, []string{pmapi.AllMailLabel}) + + require.Nil(t, m.store.deleteMessageEvent("msg1")) + + checkAllMessageIDs(t, m, []string{"msg2"}) + checkMailboxMessageIDs(t, m, pmapi.AllMailLabel, []wantID{{"msg2", 2}}) +} + +func insertMessage(t *testing.T, m *mocksForStore, id, subject, sender string, unread int, labelIDs []string) { //nolint[unparam] + msg := getTestMessage(id, subject, sender, unread, labelIDs) + require.Nil(t, m.store.createOrUpdateMessageEvent(msg)) +} + +func getTestMessage(id, subject, sender string, unread int, labelIDs []string) *pmapi.Message { + address := &mail.Address{Address: sender} + return &pmapi.Message{ + ID: id, + Subject: subject, + Unread: unread, + Sender: address, + ToList: []*mail.Address{address}, + LabelIDs: labelIDs, + Size: 12345, + Body: "body of message", + Attachments: []*pmapi.Attachment{{ + ID: "attachment1", + MessageID: id, + Name: "attachment", + }}, + } +} + +func checkAllMessageIDs(t *testing.T, m *mocksForStore, wantIDs []string) { + allIds, allErr := m.store.getAllMessageIDs() + require.Nil(t, allErr) + require.Equal(t, wantIDs, allIds) +} diff --git a/internal/store/user_sync.go b/internal/store/user_sync.go new file mode 100644 index 00000000..221eb629 --- /dev/null +++ b/internal/store/user_sync.go @@ -0,0 +1,247 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + bolt "go.etcd.io/bbolt" +) + +const syncFinishTimeKey = "sync_state" // The original key was sync_state and we want to keep compatibility. +const syncIDRangesKey = "id_ranges" +const syncIDsToBeDeletedKey = "ids_to_be_deleted" + +// updateCountsFromServer will download and set the counts. +func (store *Store) updateCountsFromServer() error { + counts, err := store.api.CountMessages("") + if err != nil { + return errors.Wrap(err, "cannot update counts from server") + } + + return store.createOrUpdateOnAPICounts(counts) +} + +// isSynced checks whether DB counts are synced with provided counts from API. +func (store *Store) isSynced(countsOnAPI []*pmapi.MessagesCount) (bool, error) { + store.log.WithField("apiCounts", countsOnAPI).Debug("Checking whether store is synced") + + // IMPORTANT: The countsOnAPI can contain duplicates due to event merge + // (ie one label can be present multiple times). It is important to + // process all counts before checking whether they are synced. + if err := store.createOrUpdateOnAPICounts(countsOnAPI); err != nil { + store.log.WithError(err).Error("Cannot update counts before check sync") + return false, err + } + + allCounts, err := store.getOnAPICounts() + if err != nil { + return false, err + } + + store.lock.Lock() + defer store.lock.Unlock() + + countsAreOK := true + for _, counts := range allCounts { + total, unread := uint(0), uint(0) + for _, address := range store.addresses { + mbox, err := address.getMailboxByID(counts.LabelID) + if err != nil { + return false, errors.Wrapf( + err, + "cannot find mailbox for address %q", + address.addressID, + ) + } + + mboxTot, mboxUnread, err := mbox.GetCounts() + if err != nil { + errW := errors.Wrap(err, "cannot count messages") + store.log. + WithError(errW). + WithField("label", counts.LabelID). + WithField("address", address.addressID). + Error("IsSynced failed") + return false, err + } + total += mboxTot + unread += mboxUnread + } + + if total != counts.TotalOnAPI || unread != counts.UnreadOnAPI { + store.log.WithFields(logrus.Fields{ + "label": counts.LabelID, + "db-total": total, + "db-unread": unread, + "api-total": counts.TotalOnAPI, + "api-unread": counts.UnreadOnAPI, + }).Warning("counts differ") + countsAreOK = false + } + } + + return countsAreOK, nil +} + +// triggerSync starts a sync of complete user by syncing All Mail mailbox. +// All Mail mailbox contains all messages, so we download all meta data needed +// to generate any address/mailbox IMAP UIDs. +// Sync state can be in three states: +// * Nothing in database. For example when user logs in for the first time. +// `triggerSync` will start full sync. +// * Database has syncIDRangesKey and syncIDsToBeDeletedKey keys with data. +// Sync is in progress or was interrupted. In later case when, `triggerSync` +// will continue where it left off. +// * Database has only syncStateKey with time when database was last synced. +// `triggerSync` will reset it and start full sync again. +func (store *Store) triggerSync() { + syncState := store.loadSyncState() + + // We first clear the last sync state in case this sync fails. + syncState.clearFinishTime() + + // We don't want sync to block. + go func() { + defer store.panicHandler.HandlePanic() + + store.log.Debug("Store sync triggered") + + store.lock.Lock() + if store.isSyncRunning { + store.lock.Unlock() + store.log.Info("Store sync is already ongoing") + return + } + store.isSyncRunning = true + store.lock.Unlock() + + defer func() { + store.lock.Lock() + store.isSyncRunning = false + store.lock.Unlock() + }() + + store.log.WithField("isIncomplete", syncState.isIncomplete()).Info("Store sync started") + + err := syncAllMail(store.panicHandler, store, store.api, syncState) + if err != nil { + log.WithError(err).Error("Store sync failed") + return + } + + syncState.setFinishTime() + }() +} + +// isSyncFinished returns whether the database has finished a sync. +func (store *Store) isSyncFinished() (isSynced bool) { + return store.loadSyncState().isFinished() +} + +// loadSyncState loads information about sync from database. +// See `triggerSync` to learn more about possible states. +func (store *Store) loadSyncState() *syncState { + finishTime := int64(0) + idRanges := []*syncIDRange{} + idsToBeDeleted := []string{} + + err := store.db.View(func(tx *bolt.Tx) (err error) { + b := tx.Bucket(syncStateBucket) + + finishTimeByte := b.Get([]byte(syncFinishTimeKey)) + if finishTimeByte != nil { + finishTime, err = strconv.ParseInt(string(finishTimeByte), 10, 64) + if err != nil { + store.log.WithError(err).Error("Failed to unmarshal sync IDs ranges") + } + } + + idRangesData := b.Get([]byte(syncIDRangesKey)) + if idRangesData != nil { + if err := json.Unmarshal(idRangesData, &idRanges); err != nil { + store.log.WithError(err).Error("Failed to unmarshal sync IDs ranges") + } + } + + idsToBeDeletedData := b.Get([]byte(syncIDsToBeDeletedKey)) + if idsToBeDeletedData != nil { + if err := json.Unmarshal(idsToBeDeletedData, &idsToBeDeleted); err != nil { + store.log.WithError(err).Error("Failed to unmarshal sync IDs to be deleted") + } + } + + return + }) + + if err != nil { + store.log.WithError(err).Error("Failed to load sync state") + } + + return newSyncState(store, finishTime, idRanges, idsToBeDeleted) +} + +// saveSyncState saves information about sync to database. +// See `triggerSync` to learn more about possible states. +func (store *Store) saveSyncState(finishTime int64, idRanges []*syncIDRange, idsToBeDeleted []string) { + idRangesData, err := json.Marshal(idRanges) + if err != nil { + store.log.WithError(err).Error("Failed to marshall sync IDs ranges") + } + + idsToBeDeletedData, err := json.Marshal(idsToBeDeleted) + if err != nil { + store.log.WithError(err).Error("Failed to marshall sync IDs to be deleted") + } + + err = store.db.Update(func(tx *bolt.Tx) (err error) { + b := tx.Bucket(syncStateBucket) + if finishTime != 0 { + curTime := []byte(fmt.Sprintf("%v", finishTime)) + if err := b.Put([]byte(syncFinishTimeKey), curTime); err != nil { + return err + } + if err := b.Delete([]byte(syncIDRangesKey)); err != nil { + return err + } + if err := b.Delete([]byte(syncIDsToBeDeletedKey)); err != nil { + return err + } + } else { + if err := b.Delete([]byte(syncFinishTimeKey)); err != nil { + return err + } + if err := b.Put([]byte(syncIDRangesKey), idRangesData); err != nil { + return err + } + if err := b.Put([]byte(syncIDsToBeDeletedKey), idsToBeDeletedData); err != nil { + return err + } + } + return nil + }) + + if err != nil { + store.log.WithError(err).Error("Failed to set sync state") + } +} diff --git a/internal/store/user_sync_test.go b/internal/store/user_sync_test.go new file mode 100644 index 00000000..ca0b9770 --- /dev/null +++ b/internal/store/user_sync_test.go @@ -0,0 +1,90 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package store + +import ( + "sort" + "testing" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadSaveSyncState(t *testing.T) { + m, clear := initMocks(t) + defer clear() + + m.newStoreNoEvents(true) + insertMessage(t, m, "msg1", "Test message 1", addrID1, 0, []string{pmapi.AllMailLabel, pmapi.InboxLabel}) + insertMessage(t, m, "msg2", "Test message 2", addrID1, 0, []string{pmapi.AllMailLabel, pmapi.InboxLabel}) + + // Clear everything. + + syncState := m.store.loadSyncState() + syncState.clearFinishTime() + + // Check everything is empty at the beginning. + + syncState = m.store.loadSyncState() + checkSyncStateAfterLoad(t, syncState, false, false, []string{}) + + // Save IDs ranges and check everything is also properly loaded. + + syncState.initIDRanges() + syncState.addIDRange("100") + syncState.addIDRange("200") + syncState.save() + + syncState = m.store.loadSyncState() + checkSyncStateAfterLoad(t, syncState, false, true, []string{}) + + // Save IDs to be deleted and check everything is properly loaded. + + require.Nil(t, syncState.loadMessageIDsToBeDeleted()) + + syncState = m.store.loadSyncState() + checkSyncStateAfterLoad(t, syncState, false, true, []string{"msg1", "msg2"}) + + // Set finish time and check everything is resetted to empty values. + + syncState.setFinishTime() + + syncState = m.store.loadSyncState() + checkSyncStateAfterLoad(t, syncState, true, false, []string{}) +} + +func checkSyncStateAfterLoad(t *testing.T, syncState *syncState, wantIsFinished bool, wantIDRanges bool, wantIDsToBeDeleted []string) { + assert.Equal(t, wantIsFinished, syncState.isFinished()) + + if wantIDRanges { + require.Equal(t, 3, len(syncState.idRanges)) + assert.Equal(t, "", syncState.idRanges[0].StartID) + assert.Equal(t, "100", syncState.idRanges[0].StopID) + assert.Equal(t, "100", syncState.idRanges[1].StartID) + assert.Equal(t, "200", syncState.idRanges[1].StopID) + assert.Equal(t, "200", syncState.idRanges[2].StartID) + assert.Equal(t, "", syncState.idRanges[2].StopID) + } else { + assert.Empty(t, syncState.idRanges) + } + + idsToBeDeleted := syncState.getIDsToBeDeleted() + sort.Strings(idsToBeDeleted) + assert.Equal(t, wantIDsToBeDeleted, idsToBeDeleted) +} diff --git a/pkg/algo/algo.go b/pkg/algo/algo.go new file mode 100644 index 00000000..e7b45bb4 --- /dev/null +++ b/pkg/algo/algo.go @@ -0,0 +1,19 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Package algo provides some algorithm utils. +package algo diff --git a/pkg/algo/sets.go b/pkg/algo/sets.go new file mode 100644 index 00000000..c244cae4 --- /dev/null +++ b/pkg/algo/sets.go @@ -0,0 +1,47 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package algo + +import "reflect" + +// SetIntersection complexity: O(n^2), could be better but this is simple enough +func SetIntersection(a, b interface{}, eq func(a, b interface{}) bool) []interface{} { + set := make([]interface{}, 0) + av := reflect.ValueOf(a) + + for i := 0; i < av.Len(); i++ { + el := av.Index(i).Interface() + if contains(b, el, eq) { + set = append(set, el) + } + } + + return set +} + +func contains(a, e interface{}, eq func(a, b interface{}) bool) bool { + v := reflect.ValueOf(a) + + for i := 0; i < v.Len(); i++ { + if eq(v.Index(i).Interface(), e) { + return true + } + } + + return false +} diff --git a/pkg/algo/sets_test.go b/pkg/algo/sets_test.go new file mode 100644 index 00000000..299a530a --- /dev/null +++ b/pkg/algo/sets_test.go @@ -0,0 +1,71 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package algo + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +type T struct { + k, v int +} + +func TestSetIntersection(t *testing.T) { + keysAreEqual := func(a, b interface{}) bool { + return a.(T).k == b.(T).k + } + + type args struct { + a interface{} + b interface{} + eq func(a, b interface{}) bool + } + + tests := []struct { + name string + args args + want interface{} + }{ + { + name: "integer sets", + args: args{a: []int{1, 2, 3}, b: []int{3, 4, 5}, eq: func(a, b interface{}) bool { return a == b }}, + want: []int{3}, + }, + { + name: "string sets", + args: args{a: []string{"1", "2", "3"}, b: []string{"3", "4", "5"}, eq: func(a, b interface{}) bool { return a == b }}, + want: []string{"3"}, + }, + { + name: "custom comp, only compare on keys, prefer first set if keys are the same", + args: args{a: []T{{k: 1, v: 1}, {k: 2, v: 2}}, b: []T{{k: 2, v: 1234}, {k: 3, v: 3}}, eq: keysAreEqual}, + want: []T{{k: 2, v: 2}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // using cmp.Equal because it handles the interfaces correctly; testify/assert doesn't + // treat these as equal because their types are different ([]interface vs []int) + if got := SetIntersection(tt.args.a, tt.args.b, tt.args.eq); cmp.Equal(got, tt.want) { + t.Errorf("SetIntersection() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/args/args.go b/pkg/args/args.go new file mode 100644 index 00000000..12edbb20 --- /dev/null +++ b/pkg/args/args.go @@ -0,0 +1,35 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package args + +import ( + "os" + "strings" +) + +// FilterProcessSerialNumberFromArgs removes additional flag from MacOS. More info ProcessSerialNumber +// http://mirror.informatimago.com/next/developer.apple.com/documentation/Carbon/Reference/Process_Manager/prmref_main/data_type_5.html#//apple_ref/doc/uid/TP30000208/C001951 +func FilterProcessSerialNumberFromArgs() { + tmp := os.Args[:0] + for _, arg := range os.Args { + if !strings.Contains(arg, "-psn_") { + tmp = append(tmp, arg) + } + } + os.Args = tmp +} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 00000000..7cbd6c3b --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,259 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package config + +import ( + "io/ioutil" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/ProtonMail/go-appdir" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/hashicorp/go-multierror" +) + +var ( + log = GetLogEntry("config") //nolint[gochecknoglobals] +) + +type appDirProvider interface { + UserConfig() string + UserCache() string + UserLogs() string +} + +type Config struct { + appName string + version string + revision string + cacheVersion string + appDirs appDirProvider + appDirsVersion appDirProvider + apiConfig *pmapi.ClientConfig +} + +// New returns fully initialized config struct. +// `appName` should be in camelCase format for folder or file names. It's also used in API +// as `AppVersion` which is converted to CamelCase. +// `version` is the version of the app (e.g. v1.2.3). +// `cacheVersion` is the version of the cache files (setting a different number will remove the old ones). +func New(appName, version, revision, cacheVersion string) *Config { + appDirs := appdir.New(filepath.Join("protonmail", appName)) + appDirsVersion := appdir.New(filepath.Join("protonmail", appName, cacheVersion)) + return newConfig(appName, version, revision, cacheVersion, appDirs, appDirsVersion) +} + +func newConfig(appName, version, revision, cacheVersion string, appDirs, appDirsVersion appDirProvider) *Config { + return &Config{ + appName: appName, + version: version, + revision: revision, + cacheVersion: cacheVersion, + appDirs: appDirs, + appDirsVersion: appDirsVersion, + apiConfig: &pmapi.ClientConfig{ + AppVersion: strings.Title(appName) + "_" + version, + ClientID: appName, + Transport: &http.Transport{ + DialContext: (&net.Dialer{Timeout: 3 * time.Second}).DialContext, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 10 * time.Second, + }, + // TokenManager should not be required, but PMAPI still doesn't handle not-set cases everywhere. + TokenManager: pmapi.NewTokenManager(), + }, + } +} + +// CreateDirs creates all folders that are necessary for bridge to properly function. +func (c *Config) CreateDirs() error { + // Log files. + if err := os.MkdirAll(c.appDirs.UserLogs(), 0700); err != nil { + return err + } + // TLS files. + if err := os.MkdirAll(c.appDirs.UserConfig(), 0750); err != nil { + return err + } + // Lock, events, preferences, user_info, db files. + if err := os.MkdirAll(c.appDirsVersion.UserCache(), 0750); err != nil { + return err + } + return nil +} + +// ClearData removes all files except the lock file. +// The lock file will be removed when the Bridge stops. +func (c *Config) ClearData() error { + dirs := []string{ + c.appDirs.UserLogs(), + c.appDirs.UserConfig(), + c.appDirs.UserCache(), + } + shouldRemove := func(filePath string) bool { + return filePath != c.GetLockPath() + } + return c.removeAllExcept(dirs, shouldRemove) +} + +// ClearOldData removes all old files, such as old log files or old versions of cache and so on. +func (c *Config) ClearOldData() error { + // `appDirs` is parent for `appDirsVersion`. + // `dir` then contains all subfolders and only `cacheVersion` should stay. + // But on Windows all files (dirs) are in the same one - we cannot remove log, lock or tls files. + dir := c.appDirs.UserCache() + + return c.removeExcept(dir, func(filePath string) bool { + fileName := filepath.Base(filePath) + return (fileName != c.cacheVersion && + !logFileRgx.MatchString(fileName) && + filePath != c.GetTLSCertPath() && + filePath != c.GetTLSKeyPath() && + filePath != c.GetEventsPath() && + filePath != c.GetIMAPCachePath() && + filePath != c.GetLockPath() && + filePath != c.GetPreferencesPath()) + }) +} + +func (c *Config) removeAllExcept(dirs []string, shouldRemove func(string) bool) error { + var result *multierror.Error + for _, dir := range dirs { + if err := c.removeExcept(dir, shouldRemove); err != nil { + result = multierror.Append(result, err) + } + } + return result.ErrorOrNil() +} + +func (c *Config) removeExcept(dir string, shouldRemove func(string) bool) error { + files, err := ioutil.ReadDir(dir) + if err != nil { + return err + } + + var result *multierror.Error + for _, file := range files { + filePath := filepath.Join(dir, file.Name()) + if !shouldRemove(filePath) { + continue + } + + if !file.IsDir() { + if err := os.RemoveAll(filePath); err != nil { + result = multierror.Append(result, err) + } + continue + } + + subDir := filepath.Join(dir, file.Name()) + if err := c.removeExcept(subDir, shouldRemove); err != nil { + result = multierror.Append(result, err) + } else { + // Remove dir itself only if it's empty. + subFiles, err := ioutil.ReadDir(subDir) + if err != nil { + result = multierror.Append(result, err) + } else if len(subFiles) == 0 { + if err := os.RemoveAll(subDir); err != nil { + result = multierror.Append(result, err) + } + } + } + } + return result.ErrorOrNil() +} + +// IsDevMode should be used for development conditions such us whether to send sentry reports. +func (c *Config) IsDevMode() bool { + return os.Getenv("PROTONMAIL_ENV") == "dev" +} + +// GetLogDir returns folder for log files. +func (c *Config) GetLogDir() string { + return c.appDirs.UserLogs() +} + +// GetLogPrefix returns prefix for log files. Bridge uses format vVERSION. +func (c *Config) GetLogPrefix() string { + return "v" + c.version + "_" + c.revision +} + +// GetTLSCertPath returns path to certificate; used for TLS servers (IMAP, SMTP and API). +func (c *Config) GetTLSCertPath() string { + return filepath.Join(c.appDirs.UserConfig(), "cert.pem") +} + +// GetTLSKeyPath returns path to private key; used for TLS servers (IMAP, SMTP and API). +func (c *Config) GetTLSKeyPath() string { + return filepath.Join(c.appDirs.UserConfig(), "key.pem") +} + +// GetDBDir returns folder for db files. +func (c *Config) GetDBDir() string { + return filepath.Join(c.appDirsVersion.UserCache()) +} + +// GetEventsPath returns path to events file containing the last processed event IDs. +func (c *Config) GetEventsPath() string { + return filepath.Join(c.appDirsVersion.UserCache(), "events.json") +} + +// GetIMAPCachePath returns path to file with IMAP status. +func (c *Config) GetIMAPCachePath() string { + return filepath.Join(c.appDirsVersion.UserCache(), "user_info.json") +} + +// GetLockPath returns path to lock file to check if bridge is already running. +func (c *Config) GetLockPath() string { + return filepath.Join(c.appDirsVersion.UserCache(), c.appName+".lock") +} + +// GetUpdateDir returns folder for update files; such as new binary. +func (c *Config) GetUpdateDir() string { + return filepath.Join(c.appDirsVersion.UserCache(), "updates") +} + +// GetPreferencesPath returns path to preference file. +func (c *Config) GetPreferencesPath() string { + return filepath.Join(c.appDirsVersion.UserCache(), "prefs.json") +} + +// GetAPIConfig returns config for ProtonMail API. +func (c *Config) GetAPIConfig() *pmapi.ClientConfig { + return c.apiConfig +} + +// GetDefaultAPIPort returns default Bridge local API port. +func (c *Config) GetDefaultAPIPort() int { + return 1042 +} + +// GetDefaultIMAPPort returns default Bridge IMAP port. +func (c *Config) GetDefaultIMAPPort() int { + return 1143 +} + +// GetDefaultSMTPPort returns default Bridge SMTP port. +func (c *Config) GetDefaultSMTPPort() int { + return 1025 +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 00000000..929ac04b --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,238 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package config + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + gomock "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" +) + +const testAppName = "bridge-test" + +var testConfigDir string //nolint[gochecknoglobals] + +func TestMain(m *testing.M) { + setupTestConfig() + setupTestLogs() + code := m.Run() + shutdownTestConfig() + shutdownTestLogs() + shutdownTestPreferences() + os.Exit(code) +} + +func setupTestConfig() { + var err error + testConfigDir, err = ioutil.TempDir("", "config") + if err != nil { + panic(err) + } +} + +func shutdownTestConfig() { + _ = os.RemoveAll(testConfigDir) +} + +type mocks struct { + t *testing.T + + ctrl *gomock.Controller + appDir *MockappDirer + appDirVersion *MockappDirer +} + +func initMocks(t *testing.T) mocks { + mockCtrl := gomock.NewController(t) + return mocks{ + t: t, + + ctrl: mockCtrl, + appDir: NewMockappDirer(mockCtrl), + appDirVersion: NewMockappDirer(mockCtrl), + } +} + +func TestClearDataLinux(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + createTestStructureLinux(m, testConfigDir) + cfg := newConfig(testAppName, "v1", "rev123", "c2", m.appDir, m.appDirVersion) + require.NoError(t, cfg.ClearData()) + checkFileNames(t, testConfigDir, []string{ + "cache", + "cache/c2", + "cache/c2/bridge-test.lock", + "config", + "logs", + }) +} + +func TestClearDataWindows(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + createTestStructureWindows(m, testConfigDir) + cfg := newConfig(testAppName, "v1", "rev123", "c2", m.appDir, m.appDirVersion) + require.NoError(t, cfg.ClearData()) + checkFileNames(t, testConfigDir, []string{ + "cache", + "cache/c2", + "cache/c2/bridge-test.lock", + "config", + }) +} + +// OldData touches only cache folder. +// Removes only c1 folder as nothing else is part of cache folder on Linux/Mac. +func TestClearOldDataLinux(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + createTestStructureLinux(m, testConfigDir) + cfg := newConfig(testAppName, "v1", "rev123", "c2", m.appDir, m.appDirVersion) + require.NoError(t, cfg.ClearOldData()) + checkFileNames(t, testConfigDir, []string{ + "cache", + "cache/c2", + "cache/c2/bridge-test.lock", + "cache/c2/events.json", + "cache/c2/mailbox-user@pm.me.db", + "cache/c2/prefs.json", + "cache/c2/updates", + "cache/c2/user_info.json", + "config", + "config/cert.pem", + "config/key.pem", + "logs", + "logs/other.log", + "logs/v1_10.log", + "logs/v1_11.log", + "logs/v2_12.log", + "logs/v2_13.log", + }) +} + +// OldData touches only cache folder. Removes everything except c2 folder +// and bridge log files which are part of cache folder on Windows. +func TestClearOldDataWindows(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + createTestStructureWindows(m, testConfigDir) + cfg := newConfig(testAppName, "v1", "rev123", "c2", m.appDir, m.appDirVersion) + require.NoError(t, cfg.ClearOldData()) + checkFileNames(t, testConfigDir, []string{ + "cache", + "cache/c2", + "cache/c2/bridge-test.lock", + "cache/c2/events.json", + "cache/c2/mailbox-user@pm.me.db", + "cache/c2/prefs.json", + "cache/c2/updates", + "cache/c2/user_info.json", + "cache/v1_10.log", + "cache/v1_11.log", + "cache/v2_12.log", + "cache/v2_13.log", + "config", + "config/cert.pem", + "config/key.pem", + }) +} + +func createTestStructureLinux(m mocks, baseDir string) { + logsDir := filepath.Join(baseDir, "logs") + configDir := filepath.Join(baseDir, "config") + cacheDir := filepath.Join(baseDir, "cache") + versionedOldCacheDir := filepath.Join(baseDir, "cache", "c1") + versionedCacheDir := filepath.Join(baseDir, "cache", "c2") + createTestStructure(m, baseDir, logsDir, configDir, cacheDir, versionedOldCacheDir, versionedCacheDir) +} + +func createTestStructureWindows(m mocks, baseDir string) { + logsDir := filepath.Join(baseDir, "cache") + configDir := filepath.Join(baseDir, "config") + cacheDir := filepath.Join(baseDir, "cache") + versionedOldCacheDir := filepath.Join(baseDir, "cache", "c1") + versionedCacheDir := filepath.Join(baseDir, "cache", "c2") + createTestStructure(m, baseDir, logsDir, configDir, cacheDir, versionedOldCacheDir, versionedCacheDir) +} + +func createTestStructure(m mocks, baseDir, logsDir, configDir, cacheDir, versionedOldCacheDir, versionedCacheDir string) { + m.appDir.EXPECT().UserLogs().Return(logsDir).AnyTimes() + m.appDir.EXPECT().UserConfig().Return(configDir).AnyTimes() + m.appDir.EXPECT().UserCache().Return(cacheDir).AnyTimes() + m.appDirVersion.EXPECT().UserCache().Return(versionedCacheDir).AnyTimes() + + require.NoError(m.t, os.RemoveAll(baseDir)) + require.NoError(m.t, os.MkdirAll(baseDir, 0700)) + require.NoError(m.t, os.MkdirAll(logsDir, 0700)) + require.NoError(m.t, os.MkdirAll(configDir, 0700)) + require.NoError(m.t, os.MkdirAll(cacheDir, 0700)) + require.NoError(m.t, os.MkdirAll(versionedOldCacheDir, 0700)) + require.NoError(m.t, os.MkdirAll(versionedCacheDir, 0700)) + require.NoError(m.t, os.MkdirAll(filepath.Join(versionedCacheDir, "updates"), 0700)) + + require.NoError(m.t, ioutil.WriteFile(filepath.Join(logsDir, "other.log"), []byte("Hello"), 0755)) + require.NoError(m.t, ioutil.WriteFile(filepath.Join(logsDir, "v1_10.log"), []byte("Hello"), 0755)) + require.NoError(m.t, ioutil.WriteFile(filepath.Join(logsDir, "v1_11.log"), []byte("Hello"), 0755)) + require.NoError(m.t, ioutil.WriteFile(filepath.Join(logsDir, "v2_12.log"), []byte("Hello"), 0755)) + require.NoError(m.t, ioutil.WriteFile(filepath.Join(logsDir, "v2_13.log"), []byte("Hello"), 0755)) + + require.NoError(m.t, ioutil.WriteFile(filepath.Join(configDir, "cert.pem"), []byte("Hello"), 0755)) + require.NoError(m.t, ioutil.WriteFile(filepath.Join(configDir, "key.pem"), []byte("Hello"), 0755)) + + require.NoError(m.t, ioutil.WriteFile(filepath.Join(versionedOldCacheDir, "prefs.json"), []byte("Hello"), 0755)) + require.NoError(m.t, ioutil.WriteFile(filepath.Join(versionedOldCacheDir, "events.json"), []byte("Hello"), 0755)) + require.NoError(m.t, ioutil.WriteFile(filepath.Join(versionedOldCacheDir, "user_info.json"), []byte("Hello"), 0755)) + require.NoError(m.t, ioutil.WriteFile(filepath.Join(versionedOldCacheDir, "mailbox-user@pm.me.db"), []byte("Hello"), 0755)) + require.NoError(m.t, ioutil.WriteFile(filepath.Join(versionedCacheDir, "prefs.json"), []byte("Hello"), 0755)) + require.NoError(m.t, ioutil.WriteFile(filepath.Join(versionedCacheDir, "events.json"), []byte("Hello"), 0755)) + require.NoError(m.t, ioutil.WriteFile(filepath.Join(versionedCacheDir, "user_info.json"), []byte("Hello"), 0755)) + require.NoError(m.t, ioutil.WriteFile(filepath.Join(versionedCacheDir, testAppName+".lock"), []byte("Hello"), 0755)) + require.NoError(m.t, ioutil.WriteFile(filepath.Join(versionedCacheDir, "mailbox-user@pm.me.db"), []byte("Hello"), 0755)) +} + +func checkFileNames(t *testing.T, dir string, expectedFileNames []string) { + fileNames := getFileNames(t, dir) + require.Equal(t, expectedFileNames, fileNames) +} + +func getFileNames(t *testing.T, dir string) []string { + files, err := ioutil.ReadDir(dir) + require.NoError(t, err) + + fileNames := []string{} + for _, file := range files { + fileNames = append(fileNames, file.Name()) + if file.IsDir() { + subDir := filepath.Join(dir, file.Name()) + subFileNames := getFileNames(t, subDir) + for _, subFileName := range subFileNames { + fileNames = append(fileNames, file.Name()+"/"+subFileName) + } + } + } + return fileNames +} diff --git a/pkg/config/logs.go b/pkg/config/logs.go new file mode 100644 index 00000000..b912c4bc --- /dev/null +++ b/pkg/config/logs.go @@ -0,0 +1,252 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package config + +import ( + "bytes" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "regexp" + "runtime" + "runtime/pprof" + "sort" + "strconv" + "time" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/sirupsen/logrus" +) + +type logConfiger interface { + GetLogDir() string + GetLogPrefix() string +} + +const ( + // Zendesk now has a file size limit of 20MB. When the last N log files + // are zipped, it should fit under 20MB. Value in MB (average file has + // few hundreds kB). + maxLogFileSize = 10 * 1024 * 1024 //nolint[gochecknoglobals] + // Including the current logfile. + maxNumberLogFiles = 3 //nolint[gochecknoglobals] +) + +// logFile is pointer to currently open file used by logrus. +var logFile *os.File //nolint[gochecknoglobals] + +var logFileRgx = regexp.MustCompile("^v.*\\.log$") //nolint[gochecknoglobals] +var logCrashRgx = regexp.MustCompile("^v.*_crash_.*\\.log$") //nolint[gochecknoglobals] + +// GetLogEntry returns logrus.Entry with PID and `packageName`. +func GetLogEntry(packageName string) *logrus.Entry { + return logrus.WithFields(logrus.Fields{ + "pkg": packageName, + }) +} + +// HandlePanic reports the crash to sentry or local file when sentry fails. +func HandlePanic(cfg *Config, output string) { + if !cfg.IsDevMode() { + c := pmapi.NewClient(cfg.GetAPIConfig(), "no-user-id") + err := c.ReportSentryCrash(fmt.Errorf(output)) + if err != nil { + log.Error("Sentry crash report failed: ", err) + } + } + + filename := getLogFilename(cfg.GetLogPrefix() + "_crash_") + filepath := filepath.Join(cfg.GetLogDir(), filename) + f, err := os.OpenFile(filepath, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0600) + if err != nil { + log.Error("Cannot open file to write crash report: ", err) + return + } + + _, _ = f.WriteString(output) + _ = pprof.Lookup("goroutine").WriteTo(f, 2) + + log.Warn("Crash report saved to ", filepath) +} + +// GetGID returns goroutine number which can be used to distiguish logs from +// the concurent processes. Keep in mind that it returns the number of routine +// which executes the function. +func GetGID() uint64 { + b := make([]byte, 64) + b = b[:runtime.Stack(b, false)] + b = bytes.TrimPrefix(b, []byte("goroutine ")) + b = b[:bytes.IndexByte(b, ' ')] + n, _ := strconv.ParseUint(string(b), 10, 64) + return n +} + +// SetupLog set up log level, formatter and output (file or stdout). +// Returns whether should be used debug for IMAP and SMTP servers. +func SetupLog(cfg logConfiger, levelFlag string) (debugClient, debugServer bool) { + level, useFile := getLogLevelAndFile(levelFlag) + + logrus.SetLevel(level) + + if useFile { + logrus.SetFormatter(&logrus.JSONFormatter{}) + setLogFile(cfg.GetLogDir(), cfg.GetLogPrefix()) + watchLogFileSize(cfg.GetLogDir(), cfg.GetLogPrefix()) + } else { + logrus.SetFormatter(&logrus.TextFormatter{ + ForceColors: true, + FullTimestamp: true, + TimestampFormat: time.StampMilli, + }) + logrus.SetOutput(os.Stdout) + } + + switch levelFlag { + case "debug-client", "debug-client-json": + debugClient = true + case "debug-server", "debug-server-json", "trace": + fmt.Println("THE LOG WILL CONTAIN **DECRYPTED** MESSAGE DATA") + log.Warning("================================================") + log.Warning("THIS LOG WILL CONTAIN **DECRYPTED** MESSAGE DATA") + log.Warning("================================================") + debugClient = true + debugServer = true + } + + return debugClient, debugServer +} + +func setLogFile(logDir, logPrefix string) { + if logFile != nil { + return + } + + filename := getLogFilename(logPrefix) + var err error + logFile, err = os.OpenFile(filepath.Join(logDir, filename), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0600) + if err != nil { + panic(err) + } + + logrus.SetOutput(logFile) + + // Users sometimes change the name of the log file. We want to always log + // information about bridge version (included in log prefix) and OS. + log.Warn("Bridge version: ", logPrefix, " ", runtime.GOOS) +} + +func getLogFilename(logPrefix string) string { + currentTime := strconv.Itoa(int(time.Now().Unix())) + return logPrefix + "_" + currentTime + ".log" +} + +func watchLogFileSize(logDir, logPrefix string) { + go func() { + for { + time.Sleep(60 * time.Second) + checkLogFileSize(logDir, logPrefix) + } + }() +} + +func checkLogFileSize(logDir, logPrefix string) { + if logFile == nil { + return + } + + stat, err := logFile.Stat() + if err != nil { + log.Error("Log file size check failed: ", err) + return + } + + if stat.Size() >= maxLogFileSize { + log.Warn("Current log file ", logFile.Name(), " is too big, opening new file") + closeLogFile() + setLogFile(logDir, logPrefix) + } + + if err := clearLogs(logDir); err != nil { + log.Error("Cannot clear logs ", err) + } +} + +func closeLogFile() { + if logFile != nil { + _ = logFile.Close() + logFile = nil + } +} + +func clearLogs(logDir string) error { + files, err := ioutil.ReadDir(logDir) + if err != nil { + return err + } + + var logsWithPrefix []string + var crashesWithPrefix []string + + for _, file := range files { + if logFileRgx.MatchString(file.Name()) { + if logCrashRgx.MatchString(file.Name()) { + crashesWithPrefix = append(crashesWithPrefix, file.Name()) + } else { + logsWithPrefix = append(logsWithPrefix, file.Name()) + } + } else { + // Older versions of Bridge stored logs in subfolders for each version. + // That also has to be cleared and the functionality can be removed after some time. + if file.IsDir() { + if err := clearLogs(filepath.Join(logDir, file.Name())); err != nil { + return err + } + } else { + removeLog(logDir, file.Name()) + } + } + } + + removeOldLogs(logDir, logsWithPrefix) + removeOldLogs(logDir, crashesWithPrefix) + return nil +} + +func removeOldLogs(logDir string, filenames []string) { + count := len(filenames) + if count <= maxNumberLogFiles { + return + } + + sort.Strings(filenames) // Sorted by timestamp: oldest first. + for _, filename := range filenames[:count-maxNumberLogFiles] { + removeLog(logDir, filename) + } +} + +func removeLog(logDir, filename string) { + // We need to be sure to delete only log files. + // Directory with logs can also contain other files. + if !logFileRgx.MatchString(filename) { + return + } + if err := os.RemoveAll(filepath.Join(logDir, filename)); err != nil { + log.Error("Cannot remove old logs ", err) + } +} diff --git a/pkg/config/logs_all.go b/pkg/config/logs_all.go new file mode 100644 index 00000000..213f9665 --- /dev/null +++ b/pkg/config/logs_all.go @@ -0,0 +1,49 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build !build_qa + +package config + +import ( + "github.com/sirupsen/logrus" +) + +func getLogLevelAndFile(levelFlag string) (level logrus.Level, useFile bool) { + useFile = true + switch levelFlag { + case "panic": + level = logrus.PanicLevel + case "fatal": + level = logrus.FatalLevel + case "error": + level = logrus.ErrorLevel + case "warn": + level = logrus.WarnLevel + case "info": + level = logrus.InfoLevel + case "debug", "debug-client", "debug-server", "debug-client-json", "debug-server-json": + level = logrus.DebugLevel + useFile = false + case "trace": + level = logrus.TraceLevel + useFile = false + default: + level = logrus.InfoLevel + } + return +} diff --git a/pkg/config/logs_qa.go b/pkg/config/logs_qa.go new file mode 100644 index 00000000..c5c3f990 --- /dev/null +++ b/pkg/config/logs_qa.go @@ -0,0 +1,50 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build build_qa + +package config + +import ( + "github.com/sirupsen/logrus" +) + +// getLogLevelAndFile for QA build is altered in a way even decrypted data are stored +// in the log file when forced with `debug-client-json` or `debug-server-json`. +func getLogLevelAndFile(levelFlag string) (level logrus.Level, useFile bool) { + useFile = true + switch levelFlag { + case "panic": + level = logrus.PanicLevel + case "fatal": + level = logrus.FatalLevel + case "error": + level = logrus.ErrorLevel + case "warn": + level = logrus.WarnLevel + case "info": + level = logrus.InfoLevel + case "debug-client-json", "debug-server-json": + level = logrus.DebugLevel + case "debug", "debug-client", "debug-server": + level = logrus.DebugLevel + useFile = false + default: + level = logrus.InfoLevel + } + return +} diff --git a/pkg/config/logs_test.go b/pkg/config/logs_test.go new file mode 100644 index 00000000..53938b34 --- /dev/null +++ b/pkg/config/logs_test.go @@ -0,0 +1,225 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package config + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/require" +) + +type testLogConfig struct{ logDir, logPrefix string } + +func (c *testLogConfig) GetLogDir() string { return c.logDir } +func (c *testLogConfig) GetLogPrefix() string { return c.logPrefix } + +var testLogDir string //nolint[gochecknoglobals] + +func setupTestLogs() { + var err error + testLogDir, err = ioutil.TempDir("", "log") + if err != nil { + panic(err) + } +} + +func shutdownTestLogs() { + _ = os.RemoveAll(testLogDir) +} + +func TestLogNameLength(t *testing.T) { + cfg := New("bridge-test", "longVersion123", "longRevision1234567890", "c2") + name := getLogFilename(cfg.GetLogPrefix()) + if len(name) > 128 { + t.Fatal("Name of the log is too long - limit for encrypted linux is 128 characters") + } +} + +// Info and higher levels writes to the file. +func TestSetupLogInfo(t *testing.T) { + dir := beforeEachCreateTestDir(t, "setupInfo") + + SetupLog(&testLogConfig{dir, "v"}, "info") + require.Equal(t, "info", logrus.GetLevel().String()) + + logrus.Info("test message") + files := checkLogFiles(t, dir, 1) + checkLogContains(t, dir, files[0].Name(), "test message") +} + +// Debug levels writes to stdout. +func TestSetupLogDebug(t *testing.T) { + dir := beforeEachCreateTestDir(t, "setupDebug") + + SetupLog(&testLogConfig{dir, "v"}, "debug") + require.Equal(t, "debug", logrus.GetLevel().String()) + + logrus.Info("test message") + checkLogFiles(t, dir, 0) +} + +func TestReopenLogFile(t *testing.T) { + dir := beforeEachCreateTestDir(t, "reopenLogFile") + + setLogFile(dir, "v1") + + done := make(chan interface{}) + + log.Info("first message") + + go func() { + <-done // Wait for closing file and opening new one. + log.Info("second message") + done <- nil + }() + + closeLogFile() + setLogFile(dir, "v2") + + done <- nil + <-done // Wait for second log message. + + files := checkLogFiles(t, dir, 2) + checkLogContains(t, dir, files[0].Name(), "first message") + checkLogContains(t, dir, files[1].Name(), "second message") +} + +func TestCheckLogFileSizeSmall(t *testing.T) { + dir := beforeEachCreateTestDir(t, "logFileSizeSmall") + + setLogFile(dir, "v1") + originalFileName := logFile.Name() + + _, _ = logFile.WriteString("small file") + checkLogFileSize(dir, "v2") + + require.Equal(t, originalFileName, logFile.Name()) +} + +func TestCheckLogFileSizeBig(t *testing.T) { + if testing.Short() { + t.Skip("skipping test in short mode.") + } + + dir := beforeEachCreateTestDir(t, "logFileSizeBig") + + setLogFile(dir, "v1") + originalFileName := logFile.Name() + + // The limit for big file is 10*1024*1024 - keep the string 10 letters long. + for i := 0; i < 1024*1024; i++ { + _, _ = logFile.WriteString("big file!\n") + } + checkLogFileSize(dir, "v2") + + require.NotEqual(t, originalFileName, logFile.Name()) +} + +// ClearLogs removes only bridge old log files keeping last three of them. +func TestClearLogsLinux(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + dir := beforeEachCreateTestDir(t, "clearLogs") + + createTestStructureLinux(m, dir) + require.NoError(t, clearLogs(dir)) + checkFileNames(t, dir, []string{ + "cache", + "cache/c1", + "cache/c1/events.json", + "cache/c1/mailbox-user@pm.me.db", + "cache/c1/prefs.json", + "cache/c1/user_info.json", + "cache/c2", + "cache/c2/bridge-test.lock", + "cache/c2/events.json", + "cache/c2/mailbox-user@pm.me.db", + "cache/c2/prefs.json", + "cache/c2/updates", + "cache/c2/user_info.json", + "config", + "config/cert.pem", + "config/key.pem", + "logs", + "logs/other.log", + "logs/v1_11.log", + "logs/v2_12.log", + "logs/v2_13.log", + }) +} + +// ClearLogs removes only bridge old log files even when log folder +// is shared with other files on Windows. +func TestClearLogsWindows(t *testing.T) { + m := initMocks(t) + defer m.ctrl.Finish() + + dir := beforeEachCreateTestDir(t, "clearLogs") + + createTestStructureWindows(m, dir) + require.NoError(t, clearLogs(dir)) + checkFileNames(t, dir, []string{ + "cache", + "cache/c1", + "cache/c1/events.json", + "cache/c1/mailbox-user@pm.me.db", + "cache/c1/prefs.json", + "cache/c1/user_info.json", + "cache/c2", + "cache/c2/bridge-test.lock", + "cache/c2/events.json", + "cache/c2/mailbox-user@pm.me.db", + "cache/c2/prefs.json", + "cache/c2/updates", + "cache/c2/user_info.json", + "cache/other.log", + "cache/v1_11.log", + "cache/v2_12.log", + "cache/v2_13.log", + "config", + "config/cert.pem", + "config/key.pem", + }) +} + +func beforeEachCreateTestDir(t *testing.T, dir string) string { + // Make sure opened file (from the previous test) is cleared. + closeLogFile() + + dir = filepath.Join(testLogDir, dir) + require.NoError(t, os.MkdirAll(dir, 0700)) + return dir +} + +func checkLogFiles(t *testing.T, dir string, expectedCount int) []os.FileInfo { + files, err := ioutil.ReadDir(dir) + require.NoError(t, err) + require.Equal(t, expectedCount, len(files)) + return files +} + +func checkLogContains(t *testing.T, dir, fileName, expectedSubstr string) { + data, err := ioutil.ReadFile(filepath.Join(dir, fileName)) //nolint[gosec] + require.NoError(t, err) + require.Contains(t, string(data), expectedSubstr) +} diff --git a/pkg/config/mock_config.go b/pkg/config/mock_config.go new file mode 100644 index 00000000..a2066ecc --- /dev/null +++ b/pkg/config/mock_config.go @@ -0,0 +1,76 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: config/config.go + +// Package config is a generated GoMock package. +package config + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockappDirer is a mock of appDirer interface +type MockappDirer struct { + ctrl *gomock.Controller + recorder *MockappDirerMockRecorder +} + +// MockappDirerMockRecorder is the mock recorder for MockappDirer +type MockappDirerMockRecorder struct { + mock *MockappDirer +} + +// NewMockappDirer creates a new mock instance +func NewMockappDirer(ctrl *gomock.Controller) *MockappDirer { + mock := &MockappDirer{ctrl: ctrl} + mock.recorder = &MockappDirerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockappDirer) EXPECT() *MockappDirerMockRecorder { + return m.recorder +} + +// UserConfig mocks base method +func (m *MockappDirer) UserConfig() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UserConfig") + ret0, _ := ret[0].(string) + return ret0 +} + +// UserConfig indicates an expected call of UserConfig +func (mr *MockappDirerMockRecorder) UserConfig() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserConfig", reflect.TypeOf((*MockappDirer)(nil).UserConfig)) +} + +// UserCache mocks base method +func (m *MockappDirer) UserCache() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UserCache") + ret0, _ := ret[0].(string) + return ret0 +} + +// UserCache indicates an expected call of UserCache +func (mr *MockappDirerMockRecorder) UserCache() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserCache", reflect.TypeOf((*MockappDirer)(nil).UserCache)) +} + +// UserLogs mocks base method +func (m *MockappDirer) UserLogs() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UserLogs") + ret0, _ := ret[0].(string) + return ret0 +} + +// UserLogs indicates an expected call of UserLogs +func (mr *MockappDirerMockRecorder) UserLogs() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserLogs", reflect.TypeOf((*MockappDirer)(nil).UserLogs)) +} diff --git a/pkg/config/preferences.go b/pkg/config/preferences.go new file mode 100644 index 00000000..8a6fdf3b --- /dev/null +++ b/pkg/config/preferences.go @@ -0,0 +1,127 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package config + +import ( + "encoding/json" + "errors" + "os" + "strconv" + "sync" +) + +type Preferences struct { + cache map[string]string + path string + lock *sync.RWMutex +} + +// NewPreferences returns loaded preferences. +func NewPreferences(preferencesPath string) *Preferences { + p := &Preferences{ + path: preferencesPath, + lock: &sync.RWMutex{}, + } + if err := p.load(); err != nil { + log.Warn("Cannot load preferences: ", err) + } + return p +} + +func (p *Preferences) load() error { + if p.cache != nil { + return nil + } + + p.lock.Lock() + defer p.lock.Unlock() + + p.cache = map[string]string{} + + f, err := os.Open(p.path) + if err != nil { + return err + } + defer f.Close() //nolint[errcheck] + + return json.NewDecoder(f).Decode(&p.cache) +} + +func (p *Preferences) save() error { + if p.cache == nil { + return errors.New("cannot save preferences: cache is nil") + } + + p.lock.Lock() + defer p.lock.Unlock() + + f, err := os.Create(p.path) + if err != nil { + return err + } + defer f.Close() //nolint[errcheck] + + return json.NewEncoder(f).Encode(p.cache) +} + +func (p *Preferences) SetDefault(key, value string) { + if p.Get(key) == "" { + p.Set(key, value) + } +} + +func (p *Preferences) Get(key string) string { + p.lock.RLock() + defer p.lock.RUnlock() + + return p.cache[key] +} + +func (p *Preferences) GetBool(key string) bool { + return p.Get(key) == "true" +} + +func (p *Preferences) GetInt(key string) int { + value, err := strconv.Atoi(p.Get(key)) + if err != nil { + log.Error("Cannot parse int: ", err) + } + return value +} + +func (p *Preferences) Set(key, value string) { + p.lock.Lock() + p.cache[key] = value + p.lock.Unlock() + + if err := p.save(); err != nil { + log.Warn("Cannot save preferences: ", err) + } +} + +func (p *Preferences) SetBool(key string, value bool) { + if value { + p.Set(key, "true") + } else { + p.Set(key, "false") + } +} + +func (p *Preferences) SetInt(key string, value int) { + p.Set(key, strconv.Itoa(value)) +} diff --git a/pkg/config/preferences_test.go b/pkg/config/preferences_test.go new file mode 100644 index 00000000..a285a882 --- /dev/null +++ b/pkg/config/preferences_test.go @@ -0,0 +1,109 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package config + +import ( + "io/ioutil" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +const testPrefFilePath = "/tmp/pref.json" + +func shutdownTestPreferences() { + _ = os.RemoveAll(testPrefFilePath) +} + +func TestLoadNoPreferences(t *testing.T) { + pref := newTestEmptyPreferences(t) + require.Equal(t, "", pref.Get("key")) +} + +func TestLoadBadPreferences(t *testing.T) { + require.NoError(t, ioutil.WriteFile(testPrefFilePath, []byte("{\"key\":\"value"), 0700)) + pref := NewPreferences(testPrefFilePath) + require.Equal(t, "", pref.Get("key")) +} + +func TestPreferencesGet(t *testing.T) { + pref := newTestPreferences(t) + require.Equal(t, "value", pref.Get("str")) + require.Equal(t, "42", pref.Get("int")) + require.Equal(t, "true", pref.Get("bool")) + require.Equal(t, "t", pref.Get("falseBool")) +} + +func TestPreferencesGetInt(t *testing.T) { + pref := newTestPreferences(t) + require.Equal(t, 0, pref.GetInt("str")) + require.Equal(t, 42, pref.GetInt("int")) + require.Equal(t, 0, pref.GetInt("bool")) + require.Equal(t, 0, pref.GetInt("falseBool")) +} + +func TestPreferencesGetBool(t *testing.T) { + pref := newTestPreferences(t) + require.Equal(t, false, pref.GetBool("str")) + require.Equal(t, false, pref.GetBool("int")) + require.Equal(t, true, pref.GetBool("bool")) + require.Equal(t, false, pref.GetBool("falseBool")) +} + +func TestPreferencesSetDefault(t *testing.T) { + pref := newTestEmptyPreferences(t) + pref.SetDefault("key", "value") + pref.SetDefault("key", "othervalue") + require.Equal(t, "value", pref.Get("key")) +} + +func TestPreferencesSet(t *testing.T) { + pref := newTestEmptyPreferences(t) + pref.Set("str", "value") + checkSavedPreferences(t, "{\"str\":\"value\"}") +} + +func TestPreferencesSetInt(t *testing.T) { + pref := newTestEmptyPreferences(t) + pref.SetInt("int", 42) + checkSavedPreferences(t, "{\"int\":\"42\"}") +} + +func TestPreferencesSetBool(t *testing.T) { + pref := newTestEmptyPreferences(t) + pref.SetBool("trueBool", true) + pref.SetBool("falseBool", false) + checkSavedPreferences(t, "{\"falseBool\":\"false\",\"trueBool\":\"true\"}") +} + +func newTestEmptyPreferences(t *testing.T) *Preferences { + require.NoError(t, os.RemoveAll(testPrefFilePath)) + return NewPreferences(testPrefFilePath) +} + +func newTestPreferences(t *testing.T) *Preferences { + require.NoError(t, ioutil.WriteFile(testPrefFilePath, []byte("{\"str\":\"value\",\"int\":\"42\",\"bool\":\"true\",\"falseBool\":\"t\"}"), 0700)) + return NewPreferences(testPrefFilePath) +} + +func checkSavedPreferences(t *testing.T, expected string) { + data, err := ioutil.ReadFile(testPrefFilePath) + require.NoError(t, err) + require.Equal(t, expected+"\n", string(data)) +} diff --git a/pkg/config/tls.go b/pkg/config/tls.go new file mode 100644 index 00000000..910080d7 --- /dev/null +++ b/pkg/config/tls.go @@ -0,0 +1,170 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package config + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "net" + "os" + "os/exec" + "runtime" + "strings" + "time" + + "github.com/kardianos/osext" +) + +type tlsConfiger interface { + GetTLSCertPath() string + GetTLSKeyPath() string +} + +var tlsTemplate = x509.Certificate{ //nolint[gochecknoglobals] + SerialNumber: big.NewInt(-1), + Subject: pkix.Name{ + Country: []string{"CH"}, + Organization: []string{"Proton Technologies AG"}, + OrganizationalUnit: []string{"ProtonMail"}, + CommonName: "127.0.0.1", + }, + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + IsCA: true, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(20 * 365 * 24 * time.Hour), +} + +var ErrTLSCertExpireSoon = fmt.Errorf("TLS certificate will expire soon") + +// GetTLSConfig tries to load TLS config or generate new one which is then returned. +func GetTLSConfig(cfg tlsConfiger) (tlsConfig *tls.Config, err error) { + certPath := cfg.GetTLSCertPath() + keyPath := cfg.GetTLSKeyPath() + tlsConfig, err = loadTLSConfig(certPath, keyPath) + if err != nil { + log.WithError(err).Warn("Cannot load cert, generating a new one") + tlsConfig, err = generateTLSConfig(certPath, keyPath) + if err != nil { + return + } + + if runtime.GOOS == "darwin" { + // If this fails, log the error but continue to load. + if p, err := osext.Executable(); err == nil { + p = strings.TrimSuffix(p, "MacOS/Desktop-Bridge") // This needs to match the executable name. + p += "Resources/addcert.scpt" + if err := exec.Command("/usr/bin/osascript", p).Run(); err != nil { // nolint[gosec] + log.WithError(err).Error("Failed to add cert to system keychain") + } + } + } + } + + tlsConfig.ServerName = "127.0.0.1" + tlsConfig.ClientAuth = tls.VerifyClientCertIfGiven + + caCertPool := x509.NewCertPool() + caCertPool.AddCert(tlsConfig.Certificates[0].Leaf) + tlsConfig.RootCAs = caCertPool + tlsConfig.ClientCAs = caCertPool + + /* This is deprecated: + * SA1019: tlsConfig.BuildNameToCertificate is deprecated: + * NameToCertificate only allows associating a single certificate with a given name. + * Leave that field nil to let the library select the first compatible chain from Certificates. + */ + tlsConfig.BuildNameToCertificate() // nolint[staticcheck] + + return tlsConfig, err +} + +func loadTLSConfig(certPath, keyPath string) (tlsConfig *tls.Config, err error) { + c, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return + } + + c.Leaf, err = x509.ParseCertificate(c.Certificate[0]) + if err != nil { + return + } + + tlsConfig = &tls.Config{ + Certificates: []tls.Certificate{c}, + } + + if time.Now().Add(31 * 24 * time.Hour).After(c.Leaf.NotAfter) { + err = ErrTLSCertExpireSoon + return + } + return +} + +// See https://golang.org/src/crypto/tls/generate_cert.go +func generateTLSConfig(certPath, keyPath string) (tlsConfig *tls.Config, err error) { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + err = fmt.Errorf("failed to generate private key: %s", err) + return + } + + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + err = fmt.Errorf("failed to generate serial number: %s", err) + return + } + + tlsTemplate.SerialNumber = serialNumber + derBytes, err := x509.CreateCertificate(rand.Reader, &tlsTemplate, &tlsTemplate, &priv.PublicKey, priv) + if err != nil { + err = fmt.Errorf("failed to create certificate: %s", err) + return + } + + certOut, err := os.Create(certPath) + if err != nil { + return + } + defer certOut.Close() //nolint[errcheck] + err = pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + if err != nil { + return + } + + keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return + } + defer keyOut.Close() //nolint[errcheck] + err = pem.Encode(keyOut, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) + if err != nil { + return + } + + return loadTLSConfig(certPath, keyPath) +} diff --git a/pkg/config/tls_test.go b/pkg/config/tls_test.go new file mode 100644 index 00000000..c3a3be08 --- /dev/null +++ b/pkg/config/tls_test.go @@ -0,0 +1,63 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package config + +import ( + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type testTLSConfig struct{ certPath, keyPath string } + +func (c *testTLSConfig) GetTLSCertPath() string { return c.certPath } +func (c *testTLSConfig) GetTLSKeyPath() string { return c.keyPath } + +func TestTLSKeyRenewal(t *testing.T) { + // Remove keys. + configPath := "/tmp" + certPath := filepath.Join(configPath, "cert.pem") + keyPath := filepath.Join(configPath, "key.pem") + _ = os.Remove(certPath) + _ = os.Remove(keyPath) + + // Put old key there. + tlsTemplate.NotBefore = time.Now().Add(-365 * 24 * time.Hour) + tlsTemplate.NotAfter = time.Now() + cert, err := generateTLSConfig(certPath, keyPath) + require.Equal(t, err, ErrTLSCertExpireSoon) + require.Equal(t, len(cert.Certificates), 1) + time.Sleep(time.Second) + now, notValidAfter := time.Now(), cert.Certificates[0].Leaf.NotAfter + require.True(t, now.After(notValidAfter), "old certificate expected to not be valid at %v but have valid until %v", now, notValidAfter) + + // Renew key. + tlsTemplate.NotBefore = time.Now() + tlsTemplate.NotAfter = time.Now().Add(2 * 365 * 24 * time.Hour) + cert, err = GetTLSConfig(&testTLSConfig{certPath, keyPath}) + if runtime.GOOS != "darwin" { // Darwin is not supported. + require.NoError(t, err) + } + require.Equal(t, len(cert.Certificates), 1) + now, notValidAfter = time.Now(), cert.Certificates[0].Leaf.NotAfter + require.False(t, now.After(notValidAfter), "new certificate expected to be valid at %v but have valid until %v", now, notValidAfter) +} diff --git a/pkg/connection/check_connection.go b/pkg/connection/check_connection.go new file mode 100644 index 00000000..c25ed92b --- /dev/null +++ b/pkg/connection/check_connection.go @@ -0,0 +1,88 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package connection + +import ( + "errors" + "fmt" + "net/http" + + "github.com/ProtonMail/proton-bridge/pkg/config" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" +) + +// Errors for possible connection issues +var ( + ErrNoInternetConnection = errors.New("no internet connection") + ErrCanNotReachAPI = errors.New("can not reach PM API") + log = config.GetLogEntry("connection") //nolint[gochecknoglobals] +) + +// CheckInternetConnection does a check of API connection. It checks two of our endpoints in parallel. +// One endpoint is part of the protonmail API, while the other is not. +// This allows us to determine whether there is a problem with the connection itself or only a problem with our API. +// Two errors can be returned, ErrNoInternetConnection or ErrCanNotReachAPI. +func CheckInternetConnection() error { + client := &http.Client{ + Transport: pmapi.NewPMAPIPinning(pmapi.CurrentUserAgent).TransportWithPinning(), + } + + // Do not cumulate timeouts, use goroutines. + retStatus := make(chan error) + retAPI := make(chan error) + + // Check protonstatus.com without SSL for performance reasons. vpn_status endpoint is fast and + // returns only OK; this endpoint is not known by the public. We check the connection only. + go checkConnection(client, "http://protonstatus.com/vpn_status", retStatus) + + // Check of API reachability also uses a fast endpoint. + go checkConnection(client, pmapi.GlobalGetRootURL()+"/tests/ping", retAPI) + + errStatus := <-retStatus + errAPI := <-retAPI + + if errStatus != nil { + if errAPI != nil { + log.Error("Checking internet connection failed with ", errStatus, " and ", errAPI) + return ErrNoInternetConnection + } + log.Warning("API OK, but status: ", errStatus) + return nil + } + + if errAPI != nil { + log.Error("Status OK, but API: ", errAPI) + return ErrCanNotReachAPI + } + + return nil +} + +func checkConnection(client *http.Client, url string, errorChannel chan error) { + resp, err := client.Get(url) + if err != nil { + errorChannel <- err + return + } + _ = resp.Body.Close() + if resp.StatusCode != 200 { + errorChannel <- fmt.Errorf("HTTP status code %d", resp.StatusCode) + return + } + errorChannel <- nil +} diff --git a/pkg/connection/check_connection_test.go b/pkg/connection/check_connection_test.go new file mode 100644 index 00000000..167d793a --- /dev/null +++ b/pkg/connection/check_connection_test.go @@ -0,0 +1,91 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package connection + +import ( + "net/http" + "os" + "testing" + "time" + + "github.com/ProtonMail/proton-bridge/pkg/dialer" + "github.com/stretchr/testify/require" +) + +const testServerPort = "18000" +const testRequestTimeout = 10 * time.Second + +func TestMain(m *testing.M) { + go startServer() + time.Sleep(100 * time.Millisecond) // We need to wait till server is fully running. + code := m.Run() + os.Exit(code) +} + +func startServer() { + http.HandleFunc("/ok", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) + }) + http.HandleFunc("/timeout", func(w http.ResponseWriter, r *http.Request) { + time.Sleep(10 * time.Second) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) + }) + http.HandleFunc("/serverError", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "error", http.StatusInternalServerError) + }) + panic(http.ListenAndServe(":"+testServerPort, nil)) +} + +func TestCheckConnection(t *testing.T) { + checkCheckConnection(t, "ok", "") +} + +func TestCheckConnectionTimeout(t *testing.T) { + if testing.Short() { + t.Skip("skipping test in short mode.") + } + + checkCheckConnection(t, "timeout", "Client.Timeout exceeded while awaiting headers") +} + +func TestCheckConnectionServerError(t *testing.T) { + checkCheckConnection(t, "serverError", "HTTP status code 500") +} + +func checkCheckConnection(t *testing.T, path string, expectedErrMessage string) { + client := dialer.DialTimeoutClient() + client.Timeout = testRequestTimeout + + ch := make(chan error) + + go checkConnection(client, "http://localhost:"+testServerPort+"/"+path, ch) + + timeout := time.After(testRequestTimeout + time.Second) + select { + case err := <-ch: + if expectedErrMessage == "" { + require.NoError(t, err) + } else { + require.Error(t, err, expectedErrMessage) + } + case <-timeout: + t.Error("checkConnection timeout failed") + } +} diff --git a/pkg/dialer/dial_client.go b/pkg/dialer/dial_client.go new file mode 100644 index 00000000..5638278d --- /dev/null +++ b/pkg/dialer/dial_client.go @@ -0,0 +1,46 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package dialer + +import ( + "net" + "net/http" + "time" +) + +const ( + // ClientTimeout is the timeout for the whole request (from dial to + // receiving the response body). It should be large enough to download + // even the largest attachments or the new binary of the Bridge, but + // should be hit if the server hangs (default is infinite which is bad). + clientTimeout = 30 * time.Minute + dialTimeout = 3 * time.Second +) + +// DialTimeoutClient creates client with overridden dialTimeout. +func DialTimeoutClient() *http.Client { + transport := &http.Transport{ + Dial: func(network, addr string) (net.Conn, error) { + return net.DialTimeout(network, addr, dialTimeout) + }, + } + return &http.Client{ + Timeout: clientTimeout, + Transport: transport, + } +} diff --git a/pkg/keychain/keychain.go b/pkg/keychain/keychain.go new file mode 100644 index 00000000..4c25c186 --- /dev/null +++ b/pkg/keychain/keychain.go @@ -0,0 +1,131 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// Package keychain implements a native secure password store for each platform. +package keychain + +import ( + "errors" + "strings" + "sync" + + "github.com/ProtonMail/proton-bridge/pkg/config" + "github.com/docker/docker-credential-helpers/credentials" +) + +const ( + KeychainVersion = "k11" //nolint[golint] +) + +var ( + log = config.GetLogEntry("bridgeUtils/keychain") //nolint[gochecknoglobals] + + ErrWrongKeychainURL = errors.New("wrong keychain base URL") + ErrMacKeychainRebuild = errors.New("keychain error -25293") + ErrMacKeychainList = errors.New("function `osxkeychain.List()` is not valid function for mac keychain. Use `Access.ListKeychain()` instead") + ErrNoKeychainInstalled = errors.New("no keychain management installed on this system") + accessLocker = &sync.Mutex{} //nolint[gochecknoglobals] +) + +// NewAccess creates a new native keychain. +func NewAccess(appName string) (*Access, error) { + newHelper, err := newKeychain() + if err != nil { + return nil, err + } + return &Access{ + helper: newHelper, + KeychainURL: "protonmail/" + appName + "/users", + KeychainOldURL: "protonmail/users", + KeychainMacURL: "ProtonMail" + strings.Title(appName) + "Service", + KeychainOldMacURL: "ProtonMailService", + }, nil +} + +type Access struct { + helper credentials.Helper + KeychainURL, + KeychainOldURL, + KeychainMacURL, + KeychainOldMacURL string +} + +func (s *Access) List() (userIDs []string, err error) { + accessLocker.Lock() + defer accessLocker.Unlock() + + var userIDByURL map[string]string + userIDByURL, err = s.ListKeychain() + + if err != nil { + return + } + + for itemURL, userID := range userIDByURL { + if itemURL == s.KeychainName(userID) { + userIDs = append(userIDs, userID) + } + + // Clean up old keychain name. + if itemURL == s.KeychainOldName(userID) { + _ = s.helper.Delete(s.KeychainOldName(userID)) + } + } + + return +} + +func (s *Access) Delete(userID string) (err error) { + accessLocker.Lock() + defer accessLocker.Unlock() + return s.helper.Delete(s.KeychainName(userID)) +} + +func (s *Access) Get(userID string) (secret string, err error) { + accessLocker.Lock() + defer accessLocker.Unlock() + _, secret, err = s.helper.Get(s.KeychainName(userID)) + return +} + +func (s *Access) Put(userID, secret string) error { + accessLocker.Lock() + defer accessLocker.Unlock() + + // On macOS, adding a credential that already exists does not update it and returns an error. + // So let's remove it first. + _ = s.helper.Delete(s.KeychainName(userID)) + + cred := &credentials.Credentials{ + ServerURL: s.KeychainName(userID), + Username: userID, + Secret: secret, + } + + return s.helper.Add(cred) +} + +func splitServiceAndID(keychainName string) (serviceName string, userID string, err error) { //nolint[unused] + splitted := strings.FieldsFunc(keychainName, func(c rune) bool { return c == '/' }) + n := len(splitted) + if n <= 1 { + return "", "", ErrWrongKeychainURL + } + userID = splitted[len(splitted)-1] + serviceName = strings.Join(splitted[:len(splitted)-1], "/") + return +} diff --git a/pkg/keychain/keychain_darwin.go b/pkg/keychain/keychain_darwin.go new file mode 100644 index 00000000..d93ba5ab --- /dev/null +++ b/pkg/keychain/keychain_darwin.go @@ -0,0 +1,140 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package keychain + +import ( + "strings" + + "github.com/docker/docker-credential-helpers/credentials" + mackeychain "github.com/keybase/go-keychain" +) + +func (s *Access) KeychainName(userID string) string { + return s.KeychainMacURL + "/" + userID +} + +func (s *Access) KeychainOldName(userID string) string { + return s.KeychainOldMacURL + "/" + userID +} + +type osxkeychain struct { +} + +func newKeychain() (credentials.Helper, error) { + log.Debug("creating osckeychain") + return &osxkeychain{}, nil +} + +func newQuery(serviceName, username string) mackeychain.Item { + query := mackeychain.NewItem() + query.SetSecClass(mackeychain.SecClassGenericPassword) + query.SetService(serviceName) + query.SetAccount(username) + return query +} + +func parseError(original error) error { + if original != nil && strings.Contains(original.Error(), "25293") { + return ErrMacKeychainRebuild + } + return original +} + +// Add appends credentials to the store (assuming old record with same ID is already deleted). +func (s *osxkeychain) Add(cred *credentials.Credentials) error { + serviceName, userID, err := splitServiceAndID(cred.ServerURL) + if err != nil { + return err + } + + query := newQuery(serviceName, userID) + query.SetData([]byte(cred.Secret)) + err = mackeychain.AddItem(query) + return parseError(err) +} + +// Delete removes credentials from the store. +func (s *osxkeychain) Delete(serverURL string) error { + serviceName, userID, err := splitServiceAndID(serverURL) + if err != nil { + return err + } + + query := newQuery(serviceName, userID) + err = mackeychain.DeleteItem(query) + if err != nil && !strings.Contains(err.Error(), "25300") { // Missing item is not error. + return err + } + return nil +} + +// Get retrieves credentials from the store. +// It returns username and secret as strings. +func (s *osxkeychain) Get(serverURL string) (userID string, secret string, err error) { + serviceName, userID, err := splitServiceAndID(serverURL) + if err != nil { + return + } + + query := newQuery(serviceName, userID) + query.SetMatchLimit(mackeychain.MatchLimitOne) + query.SetReturnData(true) + results, err := mackeychain.QueryItem(query) + if err != nil { + return "", "", parseError(err) + } + + if len(results) == 1 { + secret = string(results[0].Data) + } + + return +} + +// ListKeychain lists items in our services. +func (s *Access) ListKeychain() (userIDByURL map[string]string, err error) { + // Pick up correct service name and trim '/'. + serviceName, _, err := splitServiceAndID(s.KeychainOldName("not-id")) + if err != nil { + return + } + + userIDByURL = make(map[string]string) + + if oldIDs, err := mackeychain.GetGenericPasswordAccounts(serviceName); err == nil { + for _, userIDold := range oldIDs { + userIDByURL[s.KeychainOldName(userIDold)] = userIDold + } + } + + serviceName, _, _ = splitServiceAndID(s.KeychainName("not-id")) + if userIDs, err := mackeychain.GetGenericPasswordAccounts(serviceName); err == nil { + for _, userID := range userIDs { + userIDByURL[s.KeychainName(userID)] = userID + } + } + + return +} + +// List returns the stored serverURLs and their associated usernames. +// NOTE: This is not valid for go-keychain. Use ListKeychain instead. +func (s *osxkeychain) List() (userIDByURL map[string]string, err error) { + err = ErrMacKeychainList + return +} diff --git a/pkg/keychain/keychain_linux.go b/pkg/keychain/keychain_linux.go new file mode 100644 index 00000000..86927a8f --- /dev/null +++ b/pkg/keychain/keychain_linux.go @@ -0,0 +1,73 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package keychain + +import ( + "github.com/docker/docker-credential-helpers/credentials" + "github.com/docker/docker-credential-helpers/pass" + "github.com/docker/docker-credential-helpers/secretservice" +) + +func newKeychain() (credentials.Helper, error) { + log.Debug("creating pass") + passHelper := &pass.Pass{} + passErr := checkPassIsUsable(passHelper) + if passErr == nil { + return passHelper, nil + } + + log.Debug("creating secretservice") + sserviceHelper := &secretservice.Secretservice{} + _, sserviceErr := sserviceHelper.List() + if sserviceErr == nil { + return sserviceHelper, nil + } + + log.Error("No keychain! Pass: ", passErr, ", secretService: ", sserviceErr) + return nil, ErrNoKeychainInstalled +} + +func checkPassIsUsable(passHelper *pass.Pass) (err error) { + creds := &credentials.Credentials{ + ServerURL: "initCheck/pass", + Username: "pass", + Secret: "pass", + } + + if err = passHelper.Add(creds); err != nil { + return + } + // Pass is not asked about unlock until you try to decrypt. + if _, _, err = passHelper.Get(creds.ServerURL); err != nil { + return + } + _ = passHelper.Delete(creds.ServerURL) // Doesn't matter if you are able to clear. + return +} + +func (s *Access) KeychainName(userID string) string { + return s.KeychainURL + "/" + userID +} + +func (s *Access) KeychainOldName(userID string) string { + return s.KeychainOldURL + "/" + userID +} + +func (s *Access) ListKeychain() (map[string]string, error) { + return s.helper.List() +} diff --git a/pkg/keychain/keychain_test.go b/pkg/keychain/keychain_test.go new file mode 100644 index 00000000..0afee513 --- /dev/null +++ b/pkg/keychain/keychain_test.go @@ -0,0 +1,152 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package keychain + +import ( + "encoding/base64" + "testing" + + "github.com/stretchr/testify/require" +) + +var suffix = []byte("\x00avoidFix\x00\x00\x00\x00\x00\x00\x00") //nolint[gochecknoglobals] + +var testData = map[string]string{ //nolint[gochecknoglobals] + "user1": base64.StdEncoding.EncodeToString(append([]byte("data1"), suffix...)), + "user2": base64.StdEncoding.EncodeToString(append([]byte("data2"), suffix...)), +} + +func TestSplitServiceAndID(t *testing.T) { + acc, err := NewAccess("bridge") + require.NoError(t, err) + expectedUserID := "user" + + acc.KeychainURL = "Something/With/Several/Slashes/" + acc.KeychainMacURL = acc.KeychainURL + expectedServiceName := acc.KeychainURL + serviceName, userID, err := splitServiceAndID(acc.KeychainName(expectedUserID)) + require.NoError(t, err) + require.Equal(t, expectedUserID, userID) + require.Equal(t, expectedServiceName, serviceName+"/") + + acc.KeychainURL = "SomethingWithoutSlash" + acc.KeychainMacURL = acc.KeychainURL + expectedServiceName = acc.KeychainURL + serviceName, userID, err = splitServiceAndID(acc.KeychainName(expectedUserID)) + require.NoError(t, err) + require.Equal(t, expectedUserID, userID) + require.Equal(t, expectedServiceName, serviceName) +} + +func TestInsertReadRemove(t *testing.T) { // nolint[funlen] + if testing.Short() { + t.Skip("skipping test in short mode.") + } + + access, err := NewAccess("bridge") + require.NoError(t, err) + access.KeychainURL = "protonmail/testchain/users" + access.KeychainMacURL = "ProtonMailTestChainService" + + // Clear before test. + for id := range testData { + // Keychain can be empty. + _ = access.Delete(id) + } + + for id, secret := range testData { + expectedList, _ := access.List() + // Add expected secrets. + expectedSecret := secret + require.NoError(t, access.Put(id, expectedSecret)) + + // Check list. + actualList, err := access.List() + require.NoError(t, err) + expectedList = append(expectedList, id) + require.ElementsMatch(t, expectedList, actualList) + + // Get and check what was inserted. + actualSecret, err := access.Get(id) + require.NoError(t, err) + require.Equal(t, expectedSecret, actualSecret) + + // Put what changed. + + expectedSecret = "edited_" + id + expectedSecret = base64.StdEncoding.EncodeToString(append([]byte(expectedSecret), suffix...)) + + nJobs := 100 + nWorkers := 3 + jobs := make(chan interface{}, nJobs) + done := make(chan interface{}) + for i := 0; i < nWorkers; i++ { + go func() { + for { + _, more := <-jobs + if more { + require.NoError(t, access.Put(id, expectedSecret)) + } else { + done <- nil + return + } + } + }() + } + + for i := 0; i < nJobs; i++ { + jobs <- nil + } + close(jobs) + for i := 0; i < nWorkers; i++ { + <-done + } + + // Check list. + actualList, err = access.List() + require.NoError(t, err) + require.ElementsMatch(t, expectedList, actualList) + + // Get and check what changed. + actualSecret, err = access.Get(id) + require.NoError(t, err) + require.Equal(t, expectedSecret, actualSecret) + + if id != "user1" { + // Remove. + err = access.Delete(id) + require.NoError(t, err) + + // Check removed. + actualList, err = access.List() + require.NoError(t, err) + expectedList = expectedList[:len(expectedList)-1] + require.ElementsMatch(t, expectedList, actualList) + } + } + + // Clear first. + err = access.Delete("user1") + require.NoError(t, err) + + actualList, err := access.List() + require.NoError(t, err) + for id := range testData { + require.NotContains(t, actualList, id) + } +} diff --git a/pkg/keychain/keychain_windows.go b/pkg/keychain/keychain_windows.go new file mode 100644 index 00000000..b77c0ce1 --- /dev/null +++ b/pkg/keychain/keychain_windows.go @@ -0,0 +1,40 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package keychain + +import ( + "github.com/docker/docker-credential-helpers/credentials" + "github.com/docker/docker-credential-helpers/wincred" +) + +func newKeychain() (credentials.Helper, error) { + log.Debug("creating wincred") + return &wincred.Wincred{}, nil +} + +func (s *Access) KeychainName(userID string) string { + return s.KeychainURL + "/" + userID +} + +func (s *Access) KeychainOldName(userID string) string { + return s.KeychainOldURL + "/" + userID +} + +func (s *Access) ListKeychain() (map[string]string, error) { + return s.helper.List() +} diff --git a/pkg/listener/listener.go b/pkg/listener/listener.go new file mode 100644 index 00000000..deb79e6d --- /dev/null +++ b/pkg/listener/listener.go @@ -0,0 +1,180 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package listener + +import ( + "sync" + "time" + + "github.com/ProtonMail/proton-bridge/pkg/config" +) + +var log = config.GetLogEntry("bridgeUtils/listener") //nolint[gochecknoglobals] + +// Listener has a list of channels watching for updates. +type Listener interface { + SetLimit(eventName string, limit time.Duration) + Add(eventName string, channel chan<- string) + Remove(eventName string, channel chan<- string) + Emit(eventName string, data string) + SetBuffer(eventName string) + RetryEmit(eventName string) +} + +type listener struct { + channels map[string][]chan<- string + limits map[string]time.Duration + lastEmits map[string]map[string]time.Time + buffered map[string][]string + lock *sync.RWMutex +} + +// New returns a new Listener which initially has no topics. +func New() Listener { + return &listener{ + channels: nil, + limits: make(map[string]time.Duration), + lastEmits: make(map[string]map[string]time.Time), + buffered: make(map[string][]string), + lock: &sync.RWMutex{}, + } +} + +// SetLimit sets the limit for the `eventName`. When the same event (name and data) +// is emitted within last time duration (`limit`), event is dropped. Zero limit clears +// the limit for the specific `eventName`. +func (l *listener) SetLimit(eventName string, limit time.Duration) { + if limit == 0 { + delete(l.limits, eventName) + return + } + l.limits[eventName] = limit +} + +// Add adds an event listener. +func (l *listener) Add(eventName string, channel chan<- string) { + l.lock.Lock() + defer l.lock.Unlock() + + if l.channels == nil { + l.channels = make(map[string][]chan<- string) + } + if _, ok := l.channels[eventName]; ok { + l.channels[eventName] = append(l.channels[eventName], channel) + } else { + l.channels[eventName] = []chan<- string{channel} + } +} + +// Remove removes an event listener. +func (l *listener) Remove(eventName string, channel chan<- string) { + l.lock.Lock() + defer l.lock.Unlock() + + if _, ok := l.channels[eventName]; ok { + for i := range l.channels[eventName] { + if l.channels[eventName][i] == channel { + l.channels[eventName] = append(l.channels[eventName][:i], l.channels[eventName][i+1:]...) + break + } + } + } +} + +// Emit emits an event in parallel to all listeners (channels). +func (l *listener) Emit(eventName string, data string) { + l.emit(eventName, data, false) +} + +func (l *listener) emit(eventName, data string, isReEmit bool) { + l.lock.RLock() + defer l.lock.RUnlock() + + if !l.shouldEmit(eventName, data) { + log.Warn("Emit of ", eventName, " with data ", data, " skipped") + return + } + + if _, ok := l.channels[eventName]; ok { + for i, handler := range l.channels[eventName] { + go func(handler chan<- string, i int) { + handler <- data + log.Debugf("emitted %s data %s -> %d", eventName, data, i) + }(handler, i) + } + } else if !isReEmit { + if bufferedData, ok := l.buffered[eventName]; ok { + l.buffered[eventName] = append(bufferedData, data) + log.Debugf("Buffering event %s data %s", eventName, data) + } else { + log.Warnf("No channel is listening to %s data %s", eventName, data) + } + } +} + +func (l *listener) shouldEmit(eventName, data string) bool { + if _, ok := l.limits[eventName]; !ok { + return true + } + + l.clearLastEmits() + + if eventLastEmits, ok := l.lastEmits[eventName]; ok { + if _, ok := eventLastEmits[data]; ok { + return false + } + } else { + l.lastEmits[eventName] = make(map[string]time.Time) + } + + l.lastEmits[eventName][data] = time.Now() + return true +} + +func (l *listener) clearLastEmits() { + for eventName, lastEmits := range l.lastEmits { + limit, ok := l.limits[eventName] + if !ok { // Limits were disabled. + delete(l.lastEmits, eventName) + continue + } + for key, lastEmit := range lastEmits { + if time.Since(lastEmit) > limit { + delete(lastEmits, key) + } + } + } +} + +func (l *listener) SetBuffer(eventName string) { + if _, ok := l.buffered[eventName]; !ok { + l.buffered[eventName] = []string{} + } +} + +func (l *listener) RetryEmit(eventName string) { + if _, ok := l.channels[eventName]; !ok || len(l.channels[eventName]) == 0 { + return + } + if bufferedData, ok := l.buffered[eventName]; ok { + for _, data := range bufferedData { + l.emit(eventName, data, true) + } + l.buffered[eventName] = []string{} + } +} diff --git a/pkg/listener/listener_test.go b/pkg/listener/listener_test.go new file mode 100644 index 00000000..c984a65b --- /dev/null +++ b/pkg/listener/listener_test.go @@ -0,0 +1,172 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package listener + +import ( + "fmt" + "testing" + "time" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/require" +) + +func Example() { + eventListener := New() + + ch := make(chan string) + eventListener.Add("eventname", ch) + for eventdata := range ch { + fmt.Println(eventdata + " world") + } + + eventListener.Emit("eventname", "hello") +} + +func TestAddAndEmitSameEvent(t *testing.T) { + listener, channel := newListener() + + listener.Emit("event", "hello!") + checkChannelEmitted(t, channel, "hello!") +} + +func TestAddAndEmitDifferentEvent(t *testing.T) { + listener, channel := newListener() + + listener.Emit("other", "hello!") + checkChannelNotEmitted(t, channel) +} + +func TestAddAndRemove(t *testing.T) { + listener := New() + + channel := make(chan string) + listener.Add("event", channel) + listener.Remove("event", channel) + listener.Emit("event", "hello!") + + checkChannelNotEmitted(t, channel) +} + +func TestNoLimit(t *testing.T) { + listener, channel := newListener() + + listener.Emit("event", "hello!") + checkChannelEmitted(t, channel, "hello!") + + listener.Emit("event", "hello!") + checkChannelEmitted(t, channel, "hello!") +} + +func TestLimit(t *testing.T) { + listener, channel := newListener() + listener.SetLimit("event", 1*time.Second) + + channel2 := make(chan string) + listener.Add("event", channel2) + + listener.Emit("event", "hello!") + checkChannelEmitted(t, channel, "hello!") + checkChannelEmitted(t, channel2, "hello!") + + listener.Emit("event", "hello!") + checkChannelNotEmitted(t, channel) + checkChannelNotEmitted(t, channel2) + + time.Sleep(1 * time.Second) + + listener.Emit("event", "hello!") + checkChannelEmitted(t, channel, "hello!") + checkChannelEmitted(t, channel2, "hello!") +} + +func TestLimitDifferentData(t *testing.T) { + listener, channel := newListener() + listener.SetLimit("event", 1*time.Second) + + listener.Emit("event", "hello!") + checkChannelEmitted(t, channel, "hello!") + + listener.Emit("event", "hello?") + checkChannelEmitted(t, channel, "hello?") +} + +func TestReEmit(t *testing.T) { + logrus.SetLevel(logrus.DebugLevel) + listener := New() + listener.Emit("event", "hello?") + + listener.SetBuffer("event") + listener.SetBuffer("other") + + listener.Emit("event", "hello1") + listener.Emit("event", "hello2") + listener.Emit("other", "hello!") + listener.Emit("event", "hello3") + listener.Emit("other", "hello!") + + eventCH := make(chan string, 3) + listener.Add("event", eventCH) + + otherCH := make(chan string) + listener.Add("other", otherCH) + + listener.RetryEmit("event") + listener.RetryEmit("other") + time.Sleep(time.Millisecond) + + receivedEvents := map[string]int{} + for i := 0; i < 5; i++ { + select { + case res := <-eventCH: + receivedEvents[res]++ + case res := <-otherCH: + receivedEvents[res+":other"]++ + case <-time.After(10 * time.Millisecond): + t.Fatalf("Channel not emitted %d times", i+1) + } + } + expectedEvents := map[string]int{"hello1": 1, "hello2": 1, "hello3": 1, "hello!:other": 2} + require.Equal(t, expectedEvents, receivedEvents) +} + +func newListener() (Listener, chan string) { + listener := New() + + channel := make(chan string) + listener.Add("event", channel) + + return listener, channel +} + +func checkChannelEmitted(t testing.TB, channel chan string, expectedData string) { + select { + case res := <-channel: + require.Equal(t, expectedData, res) + case <-time.After(10 * time.Millisecond): + t.Fatalf("Channel not emitted with expected data: %s", expectedData) + } +} + +func checkChannelNotEmitted(t testing.TB, channel chan string) { + select { + case res := <-channel: + t.Fatalf("Channel emitted with a unexpected response: %s", res) + case <-time.After(10 * time.Millisecond): + } +} diff --git a/pkg/message/address.go b/pkg/message/address.go new file mode 100644 index 00000000..a1c2ae33 --- /dev/null +++ b/pkg/message/address.go @@ -0,0 +1,56 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package message + +import ( + "net/mail" + "strings" + + "github.com/emersion/go-imap" +) + +func getAddresses(addrs []*mail.Address) (imapAddrs []*imap.Address) { + for _, a := range addrs { + if a == nil { + continue + } + + parts := strings.SplitN(a.Address, "@", 2) + if len(parts) != 2 { + continue + } + + imapAddrs = append(imapAddrs, &imap.Address{ + PersonalName: a.Name, + MailboxName: parts[0], + HostName: parts[1], + }) + } + + return +} + +func formatAddressList(addrs []*mail.Address) (s string) { + for i, addr := range addrs { + if i > 0 { + s += ", " + } + s += addr.String() + } + return +} diff --git a/pkg/message/body.go b/pkg/message/body.go new file mode 100644 index 00000000..f16c569c --- /dev/null +++ b/pkg/message/body.go @@ -0,0 +1,75 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package message + +import ( + "encoding/base64" + "fmt" + "io" + "mime/quotedprintable" + + pmcrypto "github.com/ProtonMail/gopenpgp/crypto" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/emersion/go-textwrapper" + openpgperrors "golang.org/x/crypto/openpgp/errors" +) + +func WriteBody(w io.Writer, kr *pmcrypto.KeyRing, m *pmapi.Message) error { + // Decrypt body. + if err := m.Decrypt(kr); err != nil && err != openpgperrors.ErrSignatureExpired { + return err + } + if m.MIMEType != pmapi.ContentTypeMultipartMixed { + // Encode it. + qp := quotedprintable.NewWriter(w) + if _, err := io.WriteString(qp, m.Body); err != nil { + return err + } + return qp.Close() + } + _, err := io.WriteString(w, m.Body) + return err +} + +func WriteAttachmentBody(w io.Writer, kr *pmcrypto.KeyRing, m *pmapi.Message, att *pmapi.Attachment, r io.Reader) (err error) { + // Decrypt it + var dr io.Reader + dr, err = att.Decrypt(r, kr) + if err == openpgperrors.ErrKeyIncorrect { + // Do not fail if attachment is encrypted with a different key. + dr = r + err = nil + att.Name += ".gpg" + att.MIMEType = "application/pgp-encrypted" + } else if err != nil && err != openpgperrors.ErrSignatureExpired { + err = fmt.Errorf("cannot decrypt attachment: %v", err) + return + } + + // Encode it. + ww := textwrapper.NewRFC822(w) + bw := base64.NewEncoder(base64.StdEncoding, ww) + + var n int64 + if n, err = io.Copy(bw, dr); err != nil { + err = fmt.Errorf("cannot write attachment: %v (wrote %v bytes)", err, n) + } + + _ = bw.Close() + return +} diff --git a/pkg/message/envelope.go b/pkg/message/envelope.go new file mode 100644 index 00000000..45b45bc4 --- /dev/null +++ b/pkg/message/envelope.go @@ -0,0 +1,48 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package message + +import ( + "net/mail" + "time" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/emersion/go-imap" +) + +func GetEnvelope(m *pmapi.Message) *imap.Envelope { + messageID := m.ExternalID + if messageID == "" { + messageID = m.Header.Get("Message-Id") + } else { + messageID = "<" + messageID + ">" + } + + return &imap.Envelope{ + Date: time.Unix(m.Time, 0), + Subject: m.Subject, + From: getAddresses([]*mail.Address{m.Sender}), + Sender: getAddresses([]*mail.Address{m.Sender}), + ReplyTo: getAddresses(m.ReplyTos), + To: getAddresses(m.ToList), + Cc: getAddresses(m.CCList), + Bcc: getAddresses(m.BCCList), + InReplyTo: m.Header.Get("In-Reply-To"), + MessageId: messageID, + } +} diff --git a/pkg/message/flags.go b/pkg/message/flags.go new file mode 100644 index 00000000..798174ed --- /dev/null +++ b/pkg/message/flags.go @@ -0,0 +1,83 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package message + +import ( + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/emersion/go-imap" +) + +//nolint[gochecknoglobals] +var ( + AppleMailJunkFlag = imap.CanonicalFlag("$Junk") + ThunderbirdJunkFlag = imap.CanonicalFlag("Junk") + ThunderbirdNonJunkFlag = imap.CanonicalFlag("NonJunk") +) + +func GetFlags(m *pmapi.Message) (flags []string) { + if m.Unread == 0 { + flags = append(flags, imap.SeenFlag) + } + if !m.Has(pmapi.FlagSent) && !m.Has(pmapi.FlagReceived) { + flags = append(flags, imap.DraftFlag) + } + if m.Has(pmapi.FlagReplied) || m.Has(pmapi.FlagRepliedAll) { + flags = append(flags, imap.AnsweredFlag) + } + + hasSpam := false + + for _, l := range m.LabelIDs { + if l == pmapi.StarredLabel { + flags = append(flags, imap.FlaggedFlag) + } + if l == pmapi.SpamLabel { + flags = append(flags, AppleMailJunkFlag, ThunderbirdJunkFlag) + hasSpam = true + } + } + + if !hasSpam { + flags = append(flags, ThunderbirdNonJunkFlag) + } + + return +} + +func ParseFlags(m *pmapi.Message, flags []string) { + if (m.Flags & pmapi.FlagSent) == 0 { + m.Flags |= pmapi.FlagReceived + } + m.Unread = 1 + for _, f := range flags { + switch f { + case imap.SeenFlag: + m.Unread = 0 + case imap.DraftFlag: + m.Flags &= ^pmapi.FlagSent + m.Flags &= ^pmapi.FlagReceived + m.LabelIDs = append(m.LabelIDs, pmapi.DraftLabel) + case imap.FlaggedFlag: + m.LabelIDs = append(m.LabelIDs, pmapi.StarredLabel) + case imap.AnsweredFlag: + m.Flags |= pmapi.FlagReplied + case AppleMailJunkFlag, ThunderbirdJunkFlag: + m.LabelIDs = append(m.LabelIDs, pmapi.SpamLabel) + } + } +} diff --git a/pkg/message/header.go b/pkg/message/header.go new file mode 100644 index 00000000..388065e1 --- /dev/null +++ b/pkg/message/header.go @@ -0,0 +1,214 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package message + +import ( + "mime" + "net/mail" + "net/textproto" + "strings" + "time" + + pmmime "github.com/ProtonMail/proton-bridge/pkg/mime" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" +) + +// GetHeader builds the header for the message. +func GetHeader(msg *pmapi.Message) textproto.MIMEHeader { //nolint[funlen] + h := make(textproto.MIMEHeader) + + // Copy the custom header fields if there are some. + if msg.Header != nil { + h = textproto.MIMEHeader(msg.Header) + } + + // Add or rewrite fields. + h.Set("Subject", pmmime.EncodeHeader(msg.Subject)) + if msg.Sender != nil { + h.Set("From", pmmime.EncodeHeader(msg.Sender.String())) + } + if len(msg.ReplyTos) > 0 { + h.Set("Reply-To", pmmime.EncodeHeader(formatAddressList(msg.ReplyTos))) + } + if len(msg.ToList) > 0 { + h.Set("To", pmmime.EncodeHeader(formatAddressList(msg.ToList))) + } + if len(msg.CCList) > 0 { + h.Set("Cc", pmmime.EncodeHeader(formatAddressList(msg.CCList))) + } + if len(msg.BCCList) > 0 { + h.Set("Bcc", pmmime.EncodeHeader(formatAddressList(msg.BCCList))) + } + + // Add or rewrite date related fields. + if msg.Time > 0 { + h.Set("X-Pm-Date", time.Unix(msg.Time, 0).Format(time.RFC1123Z)) + if d, err := msg.Header.Date(); err != nil || d.IsZero() { // Fix date if needed. + h.Set("Date", time.Unix(msg.Time, 0).Format(time.RFC1123Z)) + } + } + + // Use External-Id if available to ensure email clients: + // * build the conversations threads correctly (Thunderbird, Mac Outlook, Apple Mail) + // * do not think the message is lost (Apple Mail) + if msg.ExternalID != "" { + h.Set("X-Pm-External-Id", "<"+msg.ExternalID+">") + if h.Get("Message-Id") == "" { + h.Set("Message-Id", "<"+msg.ExternalID+">") + } + } + if msg.ID != "" { + if h.Get("Message-Id") == "" { + h.Set("Message-Id", "<"+msg.ID+"@protonmail.internalid>") + } + h.Set("X-Pm-Internal-Id", msg.ID) + // Forward References, and include the message ID here (to improve outlook support). + if references := h.Get("References"); !strings.Contains(references, msg.ID) { + references += " <" + msg.ID + "@protonmail.internalid>" + h.Set("References", references) + } + } + if msg.ConversationID != "" { + h.Set("X-Pm-ConversationID-Id", msg.ConversationID) + if references := h.Get("References"); !strings.Contains(references, msg.ConversationID) { + references += " <" + msg.ConversationID + "@protonmail.conversationid>" + h.Set("References", references) + } + } + + return h +} + +func SetBodyContentFields(h *textproto.MIMEHeader, m *pmapi.Message) { + h.Set("Content-Type", m.MIMEType+"; charset=utf-8") + h.Set("Content-Disposition", "inline") + h.Set("Content-Transfer-Encoding", "quoted-printable") +} + +func GetBodyHeader(m *pmapi.Message) textproto.MIMEHeader { + h := make(textproto.MIMEHeader) + SetBodyContentFields(&h, m) + return h +} + +func GetRelatedHeader(m *pmapi.Message) textproto.MIMEHeader { + h := make(textproto.MIMEHeader) + h.Set("Content-Type", "multipart/related; boundary="+GetRelatedBoundary(m)) + return h +} + +func GetAttachmentHeader(att *pmapi.Attachment) textproto.MIMEHeader { + mediaType := att.MIMEType + if mediaType == "application/pgp-encrypted" { + mediaType = "application/octet-stream" + } + + encodedName := pmmime.EncodeHeader(att.Name) + disposition := "attachment" //nolint[goconst] + if strings.Contains(att.Header.Get("Content-Disposition"), "inline") { + disposition = "inline" + } + + h := make(textproto.MIMEHeader) + h.Set("Content-Type", mime.FormatMediaType(mediaType, map[string]string{"name": encodedName})) + h.Set("Content-Transfer-Encoding", "base64") + h.Set("Content-Disposition", mime.FormatMediaType(disposition, map[string]string{"filename": encodedName})) + + // Forward some original header lines. + forward := []string{"Content-Id", "Content-Description", "Content-Location"} + for _, k := range forward { + v := att.Header.Get(k) + if v != "" { + h.Set(k, v) + } + } + + return h +} + +// ========= Header parsing and sanitizing functions ========= + +func parseHeader(h mail.Header) (m *pmapi.Message, err error) { //nolint[unparam] + m = pmapi.NewMessage() + + if subject, err := pmmime.DecodeHeader(h.Get("Subject")); err == nil { + m.Subject = subject + } + if addrs, err := sanitizeAddressList(h, "From"); err == nil && len(addrs) > 0 { + m.Sender = addrs[0] + } + if addrs, err := sanitizeAddressList(h, "Reply-To"); err == nil && len(addrs) > 0 { + m.ReplyTos = addrs + } + if addrs, err := sanitizeAddressList(h, "To"); err == nil { + m.ToList = addrs + } + if addrs, err := sanitizeAddressList(h, "Cc"); err == nil { + m.CCList = addrs + } + if addrs, err := sanitizeAddressList(h, "Bcc"); err == nil { + m.BCCList = addrs + } + m.Time = 0 + if t, err := h.Date(); err == nil && !t.IsZero() { + m.Time = t.Unix() + } + + m.Header = h + return +} + +func sanitizeAddressList(h mail.Header, field string) (addrs []*mail.Address, err error) { + raw := h.Get(field) + if raw == "" { + err = mail.ErrHeaderNotPresent + return + } + var decoded string + decoded, err = pmmime.DecodeHeader(raw) + if err != nil { + return + } + addrs, err = mail.ParseAddressList(parseAddressComment(decoded)) + if err == nil { + if addrs == nil { + addrs = []*mail.Address{} + } + return + } + // Probably missing encoding error -- try to at least parse addresses in brackets. + addrStr := h.Get(field) + first := strings.Index(addrStr, "<") + last := strings.LastIndex(addrStr, ">") + if first < 0 || last < 0 || first >= last { + return + } + var addrList []string + open := first + for open < last && 0 <= open { + addrStr = addrStr[open:] + close := strings.Index(addrStr, ">") + addrList = append(addrList, addrStr[:close+1]) + addrStr = addrStr[close:] + open = strings.Index(addrStr, "<") + last = strings.LastIndex(addrStr, ">") + } + addrStr = strings.Join(addrList, ", ") + // + return mail.ParseAddressList(addrStr) +} diff --git a/pkg/message/html.go b/pkg/message/html.go new file mode 100644 index 00000000..9e3cea7e --- /dev/null +++ b/pkg/message/html.go @@ -0,0 +1,71 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package message + +import ( + "bytes" + escape "html" + "strings" + + "github.com/andybalholm/cascadia" + "golang.org/x/net/html" +) + +func plaintextToHTML(text string) (output string) { + text = escape.EscapeString(text) + text = strings.Replace(text, "\n\r", "
    ", -1) + text = strings.Replace(text, "\r\n", "
    ", -1) + text = strings.Replace(text, "\n", "
    ", -1) + text = strings.Replace(text, "\r", "
    ", -1) + + return "
    " + text + "
    " +} + +func stripHTML(input string) (stripped string, err error) { + reader := strings.NewReader(input) + doc, _ := html.Parse(reader) + body := cascadia.MustCompile("body").MatchFirst(doc) + var buf1 bytes.Buffer + if err = html.Render(&buf1, body); err != nil { + stripped = input + return + } + stripped = buf1.String() + // Handle double body tags edge case. + if strings.Index(stripped, "") + if startIndex < 5 { + return + } + stripped = stripped[startIndex+1:] + // Closing body tag is optional. + closingIndex := strings.Index(stripped, "") + if closingIndex > -1 { + stripped = stripped[:closingIndex] + } + } + return +} + +func addOuterHTMLTags(input string) (output string) { + return "" + input + "" +} + +func makeEmbeddedImageHTML(cid, name string) (output string) { + return "\""" +} diff --git a/pkg/message/message.go b/pkg/message/message.go new file mode 100644 index 00000000..56d42c76 --- /dev/null +++ b/pkg/message/message.go @@ -0,0 +1,188 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package message + +import ( + "crypto/sha512" + "fmt" + "strings" + + pmmime "github.com/ProtonMail/proton-bridge/pkg/mime" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/emersion/go-imap" + "github.com/jhillyerd/enmime" + log "github.com/sirupsen/logrus" +) + +const textPlain = "text/plain" + +func GetBoundary(m *pmapi.Message) string { + // The boundary needs to be deterministic because messages are not supposed to + // change. + return fmt.Sprintf("%x", sha512.Sum512_256([]byte(m.ID))) +} + +func GetRelatedBoundary(m *pmapi.Message) string { + // The boundary needs to be deterministic because messages are not supposed to + // change. + return fmt.Sprintf("%x", sha512.Sum512_256([]byte(m.ID+m.ID))) +} + +func GetBodyStructure(m *pmapi.Message) (bs *imap.BodyStructure) { //nolint[funlen] + bs = &imap.BodyStructure{ + MimeType: "multipart", + MimeSubType: "mixed", + Params: map[string]string{"boundary": GetBoundary(m)}, + } + var inlineParts []*imap.BodyStructure + var attParts []*imap.BodyStructure + + for _, att := range m.Attachments { + typeParts := strings.SplitN(att.MIMEType, "/", 2) + if len(typeParts) != 2 { + continue + } + + if typeParts[0] == "application" && typeParts[1] == "pgp-encrypted" { + typeParts[1] = "octet-stream" + } + + part := &imap.BodyStructure{ + MimeType: typeParts[0], + MimeSubType: typeParts[1], + Params: map[string]string{"name": att.Name}, + Encoding: "base64", + } + + if strings.Contains(att.Header.Get("Content-Disposition"), "inline") { + part.Disposition = "inline" + inlineParts = append(inlineParts, part) + } else { + part.Disposition = "attachment" + attParts = append(attParts, part) + } + } + + if len(inlineParts) > 0 { + // Set to multipart-related for inline attachments. + relatedPart := &imap.BodyStructure{ + MimeType: "multipart", + MimeSubType: "related", + Params: map[string]string{"boundary": GetRelatedBoundary(m)}, + } + + subType := "html" + + if m.MIMEType == textPlain { + subType = "plain" + } + + relatedPart.Parts = append(relatedPart.Parts, &imap.BodyStructure{ + MimeType: "text", + MimeSubType: subType, + Params: map[string]string{"charset": "utf-8"}, + Encoding: "quoted-printable", + Disposition: "inline", + }) + + bs.Parts = append(bs.Parts, relatedPart) + } else { + subType := "html" + + if m.MIMEType == textPlain { + subType = "plain" + } + + bs.Parts = append(bs.Parts, &imap.BodyStructure{ + MimeType: "text", + MimeSubType: subType, + Params: map[string]string{"charset": "utf-8"}, + Encoding: "quoted-printable", + Disposition: "inline", + }) + } + + bs.Parts = append(bs.Parts, attParts...) + + return bs +} + +func SeparateInlineAttachments(m *pmapi.Message) (atts, inlines []*pmapi.Attachment) { + for _, att := range m.Attachments { + if strings.Contains(att.Header.Get("Content-Disposition"), "inline") { + inlines = append(inlines, att) + } else { + atts = append(atts, att) + } + } + return +} + +func GetMIMEBodyStructure(m *pmapi.Message, parsedMsg *enmime.Envelope) (bs *imap.BodyStructure, err error) { + // We recursively look through the MIME structure. + root := parsedMsg.Root + if root == nil { + return GetBodyStructure(m), nil + } + + mediaType, params, err := pmmime.ParseMediaType(root.ContentType) + if err != nil { + log.Warnf("Cannot parse Content-Type '%v': %v", root.ContentType, err) + err = nil + mediaType = textPlain + } + + typeParts := strings.SplitN(mediaType, "/", 2) + + bs = &imap.BodyStructure{ + MimeType: typeParts[0], + Params: params, + } + + if len(typeParts) > 1 { + bs.MimeSubType = typeParts[1] + } + + bs.Parts = getChildrenParts(root) + + return +} + +func getChildrenParts(root *enmime.Part) (parts []*imap.BodyStructure) { + for child := root.FirstChild; child != nil; child = child.NextSibling { + mediaType, params, err := pmmime.ParseMediaType(child.ContentType) + if err != nil { + log.Warnf("Cannot parse Content-Type '%v': %v", child.ContentType, err) + mediaType = textPlain + } + typeParts := strings.SplitN(mediaType, "/", 2) + childrenParts := getChildrenParts(child) + part := &imap.BodyStructure{ + MimeType: typeParts[0], + Params: params, + Encoding: child.Charset, + Disposition: child.Disposition, + Parts: childrenParts, + } + if len(typeParts) > 1 { + part.MimeSubType = typeParts[1] + } + parts = append(parts, part) + } + return +} diff --git a/pkg/message/parser.go b/pkg/message/parser.go new file mode 100644 index 00000000..6e23d056 --- /dev/null +++ b/pkg/message/parser.go @@ -0,0 +1,468 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package message + +import ( + "bytes" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "io" + "io/ioutil" + "mime" + "mime/quotedprintable" + "net/mail" + "net/textproto" + "regexp" + "strconv" + "strings" + + pmmime "github.com/ProtonMail/proton-bridge/pkg/mime" + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/jaytaylor/html2text" + log "github.com/sirupsen/logrus" +) + +func parseAttachment(filename string, mediaType string, h textproto.MIMEHeader) (att *pmapi.Attachment) { + if decoded, err := pmmime.DecodeHeader(filename); err == nil { + filename = decoded + } + if filename == "" { + ext, err := mime.ExtensionsByType(mediaType) + if err == nil && len(ext) > 0 { + filename = "attachment" + ext[0] + } + } + + att = &pmapi.Attachment{ + Name: filename, + MIMEType: mediaType, + Header: h, + } + + headerContentID := strings.Trim(h.Get("Content-Id"), " <>") + + if headerContentID != "" { + att.ContentID = headerContentID + } + + return +} + +var reEmailComment = regexp.MustCompile("[(][^)]*[)]") //nolint[gochecknoglobals] + +// parseAddressComment removes the comments completely even though they should be allowed +// http://tools.wordtothewise.com/rfc/822 +// NOTE: This should be supported in go>1.10 but it seems it's not ¯\_(ツ)_/¯ +func parseAddressComment(raw string) string { + return reEmailComment.ReplaceAllString(raw, "") +} + +// Some clients incorrectly format messages with embedded attachments to have a format like +// I. text/plain II. attachment III. text/plain +// which we need to convert to a single HTML part with an embedded attachment. +func combineParts(m *pmapi.Message, parts []io.Reader, headers []textproto.MIMEHeader, convertPlainToHTML bool, atts *[]io.Reader) (isHTML bool, err error) { //nolint[funlen] + isHTML = true + foundText := false + + for i := len(parts) - 1; i >= 0; i-- { + part := parts[i] + h := headers[i] + + disp, dispParams, _ := pmmime.ParseMediaType(h.Get("Content-Disposition")) + + d := pmmime.DecodeContentEncoding(part, h.Get("Content-Transfer-Encoding")) + if d == nil { + log.Warnf("Unsupported Content-Transfer-Encoding '%v'", h.Get("Content-Transfer-Encoding")) + d = part + } + + contentType := h.Get("Content-Type") + if contentType == "" { + contentType = "text/plain" + } + mediaType, params, _ := pmmime.ParseMediaType(contentType) + + if strings.HasPrefix(mediaType, "text/") && mediaType != "text/calendar" && disp != "attachment" { + // This is text. + var b []byte + if b, err = ioutil.ReadAll(d); err != nil { + continue + } + b, err = pmmime.DecodeCharset(b, params) + if err != nil { + log.Warn("Decode charset error: ", err) + return false, err + } + contents := string(b) + if strings.Contains(mediaType, "text/plain") && len(contents) > 0 { + if !convertPlainToHTML { + isHTML = false + } else { + contents = plaintextToHTML(contents) + } + } else if strings.Contains(mediaType, "text/html") && len(contents) > 0 { + contents, err = stripHTML(contents) + if err != nil { + return isHTML, err + } + } + m.Body = contents + m.Body + foundText = true + } else { + // This is an attachment. + filename := dispParams["filename"] + if filename == "" { + // Using "name" in Content-Type is discouraged. + filename = params["name"] + } + if filename == "" && mediaType == "text/calendar" { + filename = "event.ics" + } + + att := parseAttachment(filename, mediaType, h) + + b := &bytes.Buffer{} + if _, err = io.Copy(b, d); err != nil { + continue + } + if foundText && att.ContentID == "" && strings.Contains(mediaType, "image") { + // Treat this as an inline attachment even though it is not marked as one. + hasher := sha256.New() + _, _ = hasher.Write([]byte(att.Name + strconv.Itoa(b.Len()))) + bytes := hasher.Sum(nil) + cid := hex.EncodeToString(bytes) + "@protonmail.com" + + att.ContentID = cid + embeddedHTML := makeEmbeddedImageHTML(cid, att.Name) + m.Body = embeddedHTML + m.Body + } + + m.Attachments = append(m.Attachments, att) + *atts = append(*atts, b) + } + } + if isHTML { + m.Body = addOuterHTMLTags(m.Body) + } + return isHTML, nil +} + +func checkHeaders(headers []textproto.MIMEHeader) bool { + foundAttachment := false + + for i := 0; i < len(headers); i++ { + h := headers[i] + + mediaType, _, _ := pmmime.ParseMediaType(h.Get("Content-Type")) + + if !strings.HasPrefix(mediaType, "text/") { + foundAttachment = true + } else if foundAttachment { + // This means that there is a text part after the first attachment, + // so we will have to convert the body from plain->HTML. + return true + } + } + return false +} + +// ============================== 7bit Filter ========================== +// For every MIME part in the tree that has "8bit" or "binary" content +// transfer encoding: transcode it to "quoted-printable". + +type SevenBitFilter struct { + target pmmime.VisitAcceptor +} + +func NewSevenBitFilter(targetAccepter pmmime.VisitAcceptor) *SevenBitFilter { + return &SevenBitFilter{ + target: targetAccepter, + } +} + +func decodePart(partReader io.Reader, header textproto.MIMEHeader) (decodedPart io.Reader) { + decodedPart = pmmime.DecodeContentEncoding(partReader, header.Get("Content-Transfer-Encoding")) + if decodedPart == nil { + log.Warnf("Unsupported Content-Transfer-Encoding '%v'", header.Get("Content-Transfer-Encoding")) + decodedPart = partReader + } + return +} + +func (sd SevenBitFilter) Accept(partReader io.Reader, header textproto.MIMEHeader, hasPlainSibling bool, isFirst, isLast bool) error { + cte := strings.ToLower(header.Get("Content-Transfer-Encoding")) + if isFirst && pmmime.IsLeaf(header) && cte != "quoted-printable" && cte != "base64" && cte != "7bit" { + decodedPart := decodePart(partReader, header) + + filteredHeader := textproto.MIMEHeader{} + for k, v := range header { + filteredHeader[k] = v + } + filteredHeader.Set("Content-Transfer-Encoding", "quoted-printable") + //filteredHeader.Set("Content-Transfer-Encoding", "base64") + + filteredBuffer := &bytes.Buffer{} + decodedSlice, _ := ioutil.ReadAll(decodedPart) + w := quotedprintable.NewWriter(filteredBuffer) + //w := base64.NewEncoder(base64.StdEncoding, filteredBuffer) + if _, err := w.Write(decodedSlice); err != nil { + log.Errorf("cannot write quotedprintable from %q: %v", cte, err) + } + if err := w.Close(); err != nil { + log.Errorf("cannot close quotedprintable from %q: %v", cte, err) + } + + _ = sd.target.Accept(filteredBuffer, filteredHeader, hasPlainSibling, true, isLast) + } else { + _ = sd.target.Accept(partReader, header, hasPlainSibling, isFirst, isLast) + } + return nil +} + +// =================== HTML Only convertor ================================== +// In any part of MIME tree structure, replace standalone text/html with +// multipart/alternative containing both text/html and text/plain. + +type HTMLOnlyConvertor struct { + target pmmime.VisitAcceptor +} + +func NewHTMLOnlyConvertor(targetAccepter pmmime.VisitAcceptor) *HTMLOnlyConvertor { + return &HTMLOnlyConvertor{ + target: targetAccepter, + } +} + +func randomBoundary() string { + var buf [30]byte + _, err := io.ReadFull(rand.Reader, buf[:]) + if err != nil { + panic(err) + } + + return fmt.Sprintf("%x", buf[:]) +} + +func (hoc HTMLOnlyConvertor) Accept(partReader io.Reader, header textproto.MIMEHeader, hasPlainSiblings bool, isFirst, isLast bool) error { + mediaType, _, err := pmmime.ParseMediaType(header.Get("Content-Type")) + if isFirst && err == nil && mediaType == "text/html" && !hasPlainSiblings { + multiPartHeaders := make(textproto.MIMEHeader) + for k, v := range header { + multiPartHeaders[k] = v + } + boundary := randomBoundary() + multiPartHeaders.Set("Content-Type", "multipart/alternative; boundary=\""+boundary+"\"") + childCte := header.Get("Content-Transfer-Encoding") + + _ = hoc.target.Accept(partReader, multiPartHeaders, false, true, false) + + partData, _ := ioutil.ReadAll(partReader) + + htmlChildHeaders := make(textproto.MIMEHeader) + htmlChildHeaders.Set("Content-Transfer-Encoding", childCte) + htmlChildHeaders.Set("Content-Type", "text/html") + htmlReader := bytes.NewReader(partData) + _ = hoc.target.Accept(htmlReader, htmlChildHeaders, false, true, false) + + _ = hoc.target.Accept(partReader, multiPartHeaders, hasPlainSiblings, false, false) + + plainChildHeaders := make(textproto.MIMEHeader) + plainChildHeaders.Set("Content-Transfer-Encoding", childCte) + plainChildHeaders.Set("Content-Type", "text/plain") + unHtmlized, err := html2text.FromReader(bytes.NewReader(partData)) + if err != nil { + unHtmlized = string(partData) + } + plainReader := strings.NewReader(unHtmlized) + _ = hoc.target.Accept(plainReader, plainChildHeaders, false, true, true) + + _ = hoc.target.Accept(partReader, multiPartHeaders, hasPlainSiblings, false, true) + } else { + _ = hoc.target.Accept(partReader, header, hasPlainSiblings, isFirst, isLast) + } + return nil +} + +// ======= Public Key Attacher ======== + +type PublicKeyAttacher struct { + target pmmime.VisitAcceptor + attachedPublicKey string + attachedPublicKeyName string + appendToMultipart bool + depth int +} + +func NewPublicKeyAttacher(targetAccepter pmmime.VisitAcceptor, attachedPublicKey, attachedPublicKeyName string) *PublicKeyAttacher { + return &PublicKeyAttacher{ + target: targetAccepter, + attachedPublicKey: attachedPublicKey, + attachedPublicKeyName: attachedPublicKeyName, + appendToMultipart: false, + depth: 0, + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func split(input string, sliceLength int) string { + processed := input + result := "" + for len(processed) > 0 { + cutPoint := min(sliceLength, len(processed)) + part := processed[0:cutPoint] + result = result + part + "\n" + processed = processed[cutPoint:] + } + return result +} + +func createKeyAttachment(publicKey, publicKeyName string) (headers textproto.MIMEHeader, contents io.Reader) { + attachmentHeaders := make(textproto.MIMEHeader) + attachmentHeaders.Set("Content-Type", "application/pgp-key; name=\""+publicKeyName+"\"") + attachmentHeaders.Set("Content-Transfer-Encoding", "base64") + attachmentHeaders.Set("Content-Disposition", "attachment; filename=\""+publicKeyName+".asc.pgp\"") + + buffer := &bytes.Buffer{} + w := base64.NewEncoder(base64.StdEncoding, buffer) + _, _ = w.Write([]byte(publicKey)) + _ = w.Close() + + return attachmentHeaders, strings.NewReader(split(buffer.String(), 73)) +} + +func (pka *PublicKeyAttacher) Accept(partReader io.Reader, header textproto.MIMEHeader, hasPlainSiblings bool, isFirst, isLast bool) error { + if isFirst && !pmmime.IsLeaf(header) { + pka.depth++ + } + if isLast && !pmmime.IsLeaf(header) { + defer func() { + pka.depth-- + }() + } + isRoot := (header.Get("From") != "") + mediaType, _, err := pmmime.ParseMediaType(header.Get("Content-Type")) + if isRoot && isFirst && err == nil && pka.attachedPublicKey != "" { //nolint[gocritic] + if strings.HasPrefix(mediaType, "multipart/mixed") { + pka.appendToMultipart = true + _ = pka.target.Accept(partReader, header, hasPlainSiblings, isFirst, isLast) + } else { + // Create two siblings with attachment in the case toplevel is not multipart/mixed. + multiPartHeaders := make(textproto.MIMEHeader) + for k, v := range header { + multiPartHeaders[k] = v + } + boundary := randomBoundary() + multiPartHeaders.Set("Content-Type", "multipart/mixed; boundary=\""+boundary+"\"") + multiPartHeaders.Del("Content-Transfer-Encoding") + + _ = pka.target.Accept(partReader, multiPartHeaders, false, true, false) + + originalHeader := make(textproto.MIMEHeader) + originalHeader.Set("Content-Type", header.Get("Content-Type")) + if header.Get("Content-Transfer-Encoding") != "" { + originalHeader.Set("Content-Transfer-Encoding", header.Get("Content-Transfer-Encoding")) + } + + _ = pka.target.Accept(partReader, originalHeader, false, true, false) + _ = pka.target.Accept(partReader, multiPartHeaders, hasPlainSiblings, false, false) + + attachmentHeaders, attachmentReader := createKeyAttachment(pka.attachedPublicKey, pka.attachedPublicKeyName) + + _ = pka.target.Accept(attachmentReader, attachmentHeaders, false, true, true) + _ = pka.target.Accept(partReader, multiPartHeaders, hasPlainSiblings, false, true) + } + } else if isLast && pka.depth == 1 && pka.attachedPublicKey != "" { + _ = pka.target.Accept(partReader, header, hasPlainSiblings, isFirst, false) + attachmentHeaders, attachmentReader := createKeyAttachment(pka.attachedPublicKey, pka.attachedPublicKeyName) + _ = pka.target.Accept(attachmentReader, attachmentHeaders, hasPlainSiblings, true, true) + _ = pka.target.Accept(partReader, header, hasPlainSiblings, isFirst, true) + } else { + _ = pka.target.Accept(partReader, header, hasPlainSiblings, isFirst, isLast) + } + return nil +} + +// ======= Parser ========== + +func Parse(r io.Reader, attachedPublicKey, attachedPublicKeyName string) (m *pmapi.Message, mimeBody string, plainContents string, atts []io.Reader, err error) { + secondReader := new(bytes.Buffer) + _, _ = secondReader.ReadFrom(r) + + mimeBody = secondReader.String() + + mm, err := mail.ReadMessage(secondReader) + if err != nil { + return + } + + if m, err = parseHeader(mm.Header); err != nil { + return + } + + h := textproto.MIMEHeader(m.Header) + mmBodyData, err := ioutil.ReadAll(mm.Body) + if err != nil { + return + } + + printAccepter := pmmime.NewMIMEPrinter() + + publicKeyAttacher := NewPublicKeyAttacher(printAccepter, attachedPublicKey, attachedPublicKeyName) + sevenBitFilter := NewSevenBitFilter(publicKeyAttacher) + + plainTextCollector := pmmime.NewPlainTextCollector(sevenBitFilter) + htmlOnlyConvertor := NewHTMLOnlyConvertor(plainTextCollector) + + visitor := pmmime.NewMimeVisitor(htmlOnlyConvertor) + err = pmmime.VisitAll(bytes.NewReader(mmBodyData), h, visitor) + /* + err = visitor.VisitAll(h, bytes.NewReader(mmBodyData)) + */ + if err != nil { + return + } + + mimeBody = printAccepter.String() + plainContents = plainTextCollector.GetPlainText() + + parts, headers, err := pmmime.GetAllChildParts(bytes.NewReader(mmBodyData), h) + + if err != nil { + return + } + + convertPlainToHTML := checkHeaders(headers) + isHTML, err := combineParts(m, parts, headers, convertPlainToHTML, &atts) + + if isHTML { + m.MIMEType = "text/html" + } else { + m.MIMEType = "text/plain" + } + + return m, mimeBody, plainContents, atts, err +} diff --git a/pkg/message/parser_test.go b/pkg/message/parser_test.go new file mode 100644 index 00000000..58305562 --- /dev/null +++ b/pkg/message/parser_test.go @@ -0,0 +1,107 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package message + +import ( + "net/mail" + "testing" +) + +func TestRFC822AddressFormat(t *testing.T) { //nolint[funlen] + tests := []struct { + address string + expected []string + }{ + { + " normal name ", + []string{ + "\"normal name\" ", + }, + }, + { + " \"comma, name\" ", + []string{ + "\"comma, name\" ", + }, + }, + { + " name (ignore comment)", + []string{ + "\"name\" ", + }, + }, + { + " name (ignore comment) , (Comment as name) username2@server.com", + []string{ + "\"name\" ", + "", + }, + }, + { + " normal name , (comment)All.(around)address@(the)server.com", + []string{ + "\"normal name\" ", + "", + }, + }, + { + " normal name , All.(\"comma, in comment\")address@(the)server.com", + []string{ + "\"normal name\" ", + "", + }, + }, + { + " \"normal name\" , \"comma, name\" ", + []string{ + "\"normal name\" ", + "\"comma, name\" ", + }, + }, + { + " \"comma, one\" , \"comma, two\" ", + []string{ + "\"comma, one\" ", + "\"comma, two\" ", + }, + }, + { + " \"comma, name\" , another, name ", + []string{ + "\"comma, name\" ", + "\"another, name\" ", + }, + }, + } + + for _, data := range tests { + uncommented := parseAddressComment(data.address) + result, err := mail.ParseAddressList(uncommented) + if err != nil { + t.Errorf("Can not parse '%s' created from '%s': %v", uncommented, data.address, err) + } + if len(result) != len(data.expected) { + t.Errorf("Wrong parsing of '%s' created from '%s': expected '%s' but have '%+v'", uncommented, data.address, data.expected, result) + } + for i, result := range result { + if data.expected[i] != result.String() { + t.Errorf("Wrong parsing\nof %q\ncreated from %q:\nexpected %q\nbut have %q", uncommented, data.address, data.expected, result.String()) + } + } + } +} diff --git a/pkg/message/section.go b/pkg/message/section.go new file mode 100644 index 00000000..3cfde487 --- /dev/null +++ b/pkg/message/section.go @@ -0,0 +1,413 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package message + +import ( + "bufio" + "bytes" + "errors" + "io" + "io/ioutil" + "net/textproto" + "strconv" + "strings" + + pmmime "github.com/ProtonMail/proton-bridge/pkg/mime" + "github.com/emersion/go-imap" +) + +type sectionInfo struct { + header textproto.MIMEHeader + start, bsize, size, lines int + reader io.Reader +} + +// Count and read. +func (si *sectionInfo) Read(p []byte) (n int, err error) { + n, err = si.reader.Read(p) + si.size += n + si.lines += bytes.Count(p, []byte("\n")) + return +} + +type boundaryReader struct { + reader *bufio.Reader + + closed, first bool + skipped int + + nl []byte // "\r\n" or "\n" (set after seeing first boundary line) + nlDashBoundary []byte // nl + "--boundary" + dashBoundaryDash []byte // "--boundary--" + dashBoundary []byte // "--boundary" +} + +func newBoundaryReader(r *bufio.Reader, boundary string) (br *boundaryReader, err error) { + b := []byte("\r\n--" + boundary + "--") + br = &boundaryReader{ + reader: r, + closed: false, + first: true, + nl: b[:2], + nlDashBoundary: b[:len(b)-2], + dashBoundaryDash: b[2:], + dashBoundary: b[2 : len(b)-2], + } + err = br.WriteNextPartTo(nil) + return +} + +func skipLWSPChar(b []byte) []byte { + for len(b) > 0 && (b[0] == ' ' || b[0] == '\t') { + b = b[1:] + } + return b +} + +func (br *boundaryReader) isFinalBoundary(line []byte) bool { + if !bytes.HasPrefix(line, br.dashBoundaryDash) { + return false + } + rest := line[len(br.dashBoundaryDash):] + rest = skipLWSPChar(rest) + return len(rest) == 0 || bytes.Equal(rest, br.nl) +} + +func (br *boundaryReader) isBoundaryDelimiterLine(line []byte) (ret bool) { + if !bytes.HasPrefix(line, br.dashBoundary) { + return false + } + rest := line[len(br.dashBoundary):] + rest = skipLWSPChar(rest) + + if br.first && len(rest) == 1 && rest[0] == '\n' { + br.nl = br.nl[1:] + br.nlDashBoundary = br.nlDashBoundary[1:] + } + return bytes.Equal(rest, br.nl) +} + +func (br *boundaryReader) WriteNextPartTo(part io.Writer) (err error) { + if br.closed { + return io.EOF + } + + var line, slice []byte + br.skipped = 0 + + for { + slice, err = br.reader.ReadSlice('\n') + line = append(line, slice...) + if err == bufio.ErrBufferFull { + continue + } + + br.skipped += len(line) + + if err == io.EOF && br.isFinalBoundary(line) { + err = nil + br.closed = true + return + } + + if err != nil { + return + } + + if br.isBoundaryDelimiterLine(line) { + br.first = false + return + } + + if br.isFinalBoundary(line) { + br.closed = true + return + } + + if part != nil { + if _, err = part.Write(line); err != nil { + return + } + } + + line = []byte{} + } +} + +type BodyStructure map[string]*sectionInfo + +func NewBodyStructure(reader io.Reader) (structure *BodyStructure, err error) { + structure = &BodyStructure{} + err = structure.Parse(reader) + return +} + +func (bs *BodyStructure) Parse(r io.Reader) error { + return bs.parseAllChildSections(r, []int{}, 0) +} + +func (bs *BodyStructure) parseAllChildSections(r io.Reader, currentPath []int, start int) (err error) { //nolint[funlen] + info := §ionInfo{ + start: start, + size: 0, + bsize: 0, + lines: 0, + reader: r, + } + + bufInfo := bufio.NewReader(info) + tp := textproto.NewReader(bufInfo) + + if info.header, err = tp.ReadMIMEHeader(); err != nil { + return + } + + bodyInfo := §ionInfo{reader: tp.R} + bodyReader := bufio.NewReader(bodyInfo) + + mediaType, params, _ := pmmime.ParseMediaType(info.header.Get("Content-Type")) + + // If multipart, call getAllParts, else read to count lines. + if (strings.HasPrefix(mediaType, "multipart/") || mediaType == "message/rfc822") && params["boundary"] != "" { + newPath := append(currentPath, 1) + + var br *boundaryReader + br, err = newBoundaryReader(bodyReader, params["boundary"]) + // New reader seeks first boundary. + if err != nil { + // Return also EOF. + return + } + + for err == nil { + start += br.skipped + part := &bytes.Buffer{} + err = br.WriteNextPartTo(part) + if err != nil { + break + } + err = bs.parseAllChildSections(part, newPath, start) + part.Reset() + newPath[len(newPath)-1]++ + } + br.reader = nil + + if err == io.EOF { + err = nil + } + if err != nil { + return + } + } else { + // Count length. + _, _ = bodyReader.WriteTo(ioutil.Discard) + } + + // Clear all buffers. + bodyReader = nil + bodyInfo.reader = nil + tp.R = nil + tp = nil + bufInfo = nil // nolint + info.reader = nil + + // Store boundaries. + info.bsize = bodyInfo.size + path := stringPathFromInts(currentPath) + (*bs)[path] = info + + // Fix start of subsections. + newPath := append(currentPath, 1) + shift := info.size - info.bsize + subInfo, err := bs.getInfo(newPath) + + // If it has subparts. + for err == nil { + subInfo.start += shift + + // Level down. + subInfo, err = bs.getInfo(append(newPath, 1)) + if err == nil { + newPath = append(newPath, 1) + continue + } + + // Next. + newPath[len(newPath)-1]++ + subInfo, err = bs.getInfo(newPath) + if err == nil { + continue + } + + // Level up. + for { + newPath = newPath[:len(newPath)-1] + if len(newPath) > 0 { + newPath[len(newPath)-1]++ + subInfo, err = bs.getInfo(newPath) + if err != nil { + err = nil + continue + } + } + break + } + + // The end. + if len(newPath) == 0 { + break + } + } + + return nil +} + +func stringPathFromInts(ints []int) (ret string) { + for i, n := range ints { + if i != 0 { + ret += "." + } + ret += strconv.Itoa(n) + } + return +} + +func (bs *BodyStructure) getInfo(sectionPath []int) (sectionInfo *sectionInfo, err error) { + path := stringPathFromInts(sectionPath) + sectionInfo, ok := (*bs)[path] + if !ok { + err = errors.New("wrong section " + path) + } + return +} + +func (bs *BodyStructure) GetSection(wholeMail io.ReadSeeker, sectionPath []int) (section []byte, err error) { + info, err := bs.getInfo(sectionPath) + if err != nil { + return + } + if _, err = wholeMail.Seek(int64(info.start), io.SeekStart); err != nil { + return + } + section = make([]byte, info.size) + _, err = wholeMail.Read(section) + return +} + +func (bs *BodyStructure) GetSectionContent(wholeMail io.ReadSeeker, sectionPath []int) (section []byte, err error) { + info, err := bs.getInfo(sectionPath) + if err != nil { + return + } + if _, err = wholeMail.Seek(int64(info.start+info.size-info.bsize), io.SeekStart); err != nil { + return + } + section = make([]byte, info.bsize) + _, err = wholeMail.Read(section) + return + + /* This is slow: + sectionBuf, err := bs.GetSection(wholeMail, sectionPath) + if err != nil { + return + } + + tp := textproto.NewReader(bufio.NewReader(buf)) + if _, err = tp.ReadMIMEHeader(); err != nil { + return err + } + + sectionBuf = &bytes.Buffer{} + _, err = io.Copy(sectionBuf, tp.R) + return + */ +} + +func (bs *BodyStructure) GetSectionHeader(sectionPath []int) (header textproto.MIMEHeader, err error) { + info, err := bs.getInfo(sectionPath) + if err != nil { + return + } + header = info.header + return +} + +func (bs *BodyStructure) Size() uint32 { + info, err := bs.getInfo([]int{}) + if err != nil { + return uint32(0) + } + return uint32(info.size) +} + +func (bs *BodyStructure) IMAPBodyStructure(currentPart []int) (imapBS *imap.BodyStructure, err error) { + var info *sectionInfo + if info, err = bs.getInfo(currentPart); err != nil { + return + } + + mediaType, params, _ := pmmime.ParseMediaType(info.header.Get("Content-Type")) + + mediaTypeSep := strings.Split(mediaType, "/") + + // If it is empty or missing it will not crash. + mediaTypeSep = append(mediaTypeSep, "") + + imapBS = &imap.BodyStructure{ + MimeType: mediaTypeSep[0], + MimeSubType: mediaTypeSep[1], + Params: params, + Size: uint32(info.bsize), + Lines: uint32(info.lines), + } + + if val := info.header.Get("Content-ID"); val != "" { + imapBS.Id = val + } + + if val := info.header.Get("Content-Transfer-Encoding"); val != "" { + imapBS.Encoding = val + } + + if val := info.header.Get("Content-Description"); val != "" { + imapBS.Description = val + } + + if val := info.header.Get("Content-Disposition"); val != "" { + imapBS.Disposition = val + } + + nextPart := append(currentPart, 1) + for { + if _, err := bs.getInfo(nextPart); err != nil { + break + } + var subStruct *imap.BodyStructure + subStruct, err = bs.IMAPBodyStructure(nextPart) + if err != nil { + return + } + if imapBS.Parts == nil { + imapBS.Parts = []*imap.BodyStructure{} + } + imapBS.Parts = append(imapBS.Parts, subStruct) + nextPart[len(nextPart)-1]++ + } + + return imapBS, nil +} diff --git a/pkg/message/section_test.go b/pkg/message/section_test.go new file mode 100644 index 00000000..d1073caa --- /dev/null +++ b/pkg/message/section_test.go @@ -0,0 +1,414 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package message + +import ( + "fmt" + "path/filepath" + "runtime" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func debug(msg string, v ...interface{}) { + _, file, line, _ := runtime.Caller(1) + fmt.Printf("%s:%d: \033[2;33m"+msg+"\033[0;39m\n", append([]interface{}{filepath.Base(file), line}, v...)...) +} + +func TestParseBodyStructure(t *testing.T) { + expectedStructure := map[string]string{ + "": "multipart/mixed; boundary=\"0000MAIN\"", + "1": "text/plain", + "2": "application/octet-stream", + "3": "message/rfc822; boundary=\"0003MSG\"", + "3.1": "text/plain", + "3.2": "application/octet-stream", + "4": "multipart/mixed; boundary=\"0004ATTACH\"", + "4.1": "image/gif", + "4.2": "message/rfc822; boundary=\"0042MSG\"", + "4.2.1": "text/plain", + "4.2.2": "multipart/alternative; boundary=\"0422ALTER\"", + "4.2.2.1": "text/plain", + "4.2.2.2": "text/html", + } + mailReader := strings.NewReader(sampleMail) + bs, err := NewBodyStructure(mailReader) + require.NoError(t, err) + + paths := []string{} + for path := range *bs { + paths = append(paths, path) + } + sort.Strings(paths) + + debug("%10s: %-50s %5s %5s %5s %5s", "section", "type", "start", "size", "bsize", "lines") + for _, path := range paths { + sec := (*bs)[path] + contentType := sec.header.Get("Content-Type") + debug("%10s: %-50s %5d %5d %5d %5d", path, contentType, sec.start, sec.size, sec.bsize, sec.lines) + require.Equal(t, expectedStructure[path], contentType) + } + + require.True(t, len(*bs) == len(expectedStructure), "Wrong number of sections expected %d but have %d", len(expectedStructure), len(*bs)) +} + +func TestGetSection(t *testing.T) { + structReader := strings.NewReader(sampleMail) + bs, err := NewBodyStructure(structReader) + require.NoError(t, err) + // Whole section. + for _, try := range testPaths { + mailReader := strings.NewReader(sampleMail) + info, err := bs.getInfo(try.path) + require.NoError(t, err) + section, err := bs.GetSection(mailReader, try.path) + require.NoError(t, err) + + debug("section %v: %d %d\n___\n%s\n‾‾‾\n", try.path, info.start, info.size, string(section)) + + require.True(t, string(section) == try.expectedSection, "not same as expected:\n___\n%s\n‾‾‾", try.expectedSection) + } + // Body content. + for _, try := range testPaths { + mailReader := strings.NewReader(sampleMail) + info, err := bs.getInfo(try.path) + require.NoError(t, err) + section, err := bs.GetSectionContent(mailReader, try.path) + require.NoError(t, err) + + debug("content %v: %d %d\n___\n%s\n‾‾‾\n", try.path, info.start+info.size-info.bsize, info.bsize, string(section)) + + require.True(t, string(section) == try.expectedBody, "not same as expected:\n___\n%s\n‾‾‾", try.expectedBody) + } +} + +/* Structure example: +HEADER ([RFC-2822] header of the message) +TEXT ([RFC-2822] text body of the message) MULTIPART/MIXED +1 TEXT/PLAIN +2 APPLICATION/OCTET-STREAM +3 MESSAGE/RFC822 +3.HEADER ([RFC-2822] header of the message) +3.TEXT ([RFC-2822] text body of the message) MULTIPART/MIXED +3.1 TEXT/PLAIN +3.2 APPLICATION/OCTET-STREAM +4 MULTIPART/MIXED +4.1 IMAGE/GIF +4.1.MIME ([MIME-IMB] header for the IMAGE/GIF) +4.2 MESSAGE/RFC822 +4.2.HEADER ([RFC-2822] header of the message) +4.2.TEXT ([RFC-2822] text body of the message) MULTIPART/MIXED +4.2.1 TEXT/PLAIN +4.2.2 MULTIPART/ALTERNATIVE +4.2.2.1 TEXT/PLAIN +4.2.2.2 TEXT/RICHTEXT +*/ + +var sampleMail = `Subject: Sample mail +From: John Doe +To: Mary Smith +Date: Fri, 21 Nov 1997 09:55:06 -0600 +Content-Type: multipart/mixed; boundary="0000MAIN" + +main summary + +--0000MAIN +Content-Type: text/plain + +1. main message + + +--0000MAIN +Content-Type: application/octet-stream +Content-Disposition: inline; filename="main_signature.sig" +Content-Transfer-Encoding: base64 + +2/MainOctetStream + +--0000MAIN +Subject: Inside mail 3 +From: Mary Smith +To: John Doe +Date: Fri, 20 Nov 1997 09:55:06 -0600 +Content-Type: message/rfc822; boundary="0003MSG" + +3. message summary + +--0003MSG +Content-Type: text/plain + +3.1 message text + +--0003MSG +Content-Type: application/octet-stream +Content-Disposition: attachment; filename="msg_3_signature.sig" +Content-Transfer-Encoding: base64 + +3/2/MessageOctestStream/== + +--0003MSG-- + +--0000MAIN +Content-Type: multipart/mixed; boundary="0004ATTACH" + +4 attach summary + +--0004ATTACH +Content-Type: image/gif +Content-Disposition: attachment; filename="att4.1_gif.sig" +Content-Transfer-Encoding: base64 + +4/1/Gif= + +--0004ATTACH +Subject: Inside mail 4.2 +From: Mary Smith +To: John Doe +Date: Fri, 10 Nov 1997 09:55:06 -0600 +Content-Type: message/rfc822; boundary="0042MSG" + +4.2 message summary + +--0042MSG +Content-Type: text/plain + +4.2.1 message text + +--0042MSG +Content-Type: multipart/alternative; boundary="0422ALTER" + +4.2.2 alternative summary + +--0422ALTER +Content-Type: text/plain + +4.2.2.1 plain text + +--0422ALTER +Content-Type: text/html + +

    4.2.2.2 html text

    + +--0422ALTER-- + +--0042MSG-- + +--0004ATTACH-- + +--0000MAIN-- + + +` + +var testPaths = []struct { + path []int + expectedSection, expectedBody string +}{ + {[]int{}, + sampleMail, + `main summary + +--0000MAIN +Content-Type: text/plain + +1. main message + + +--0000MAIN +Content-Type: application/octet-stream +Content-Disposition: inline; filename="main_signature.sig" +Content-Transfer-Encoding: base64 + +2/MainOctetStream + +--0000MAIN +Subject: Inside mail 3 +From: Mary Smith +To: John Doe +Date: Fri, 20 Nov 1997 09:55:06 -0600 +Content-Type: message/rfc822; boundary="0003MSG" + +3. message summary + +--0003MSG +Content-Type: text/plain + +3.1 message text + +--0003MSG +Content-Type: application/octet-stream +Content-Disposition: attachment; filename="msg_3_signature.sig" +Content-Transfer-Encoding: base64 + +3/2/MessageOctestStream/== + +--0003MSG-- + +--0000MAIN +Content-Type: multipart/mixed; boundary="0004ATTACH" + +4 attach summary + +--0004ATTACH +Content-Type: image/gif +Content-Disposition: attachment; filename="att4.1_gif.sig" +Content-Transfer-Encoding: base64 + +4/1/Gif= + +--0004ATTACH +Subject: Inside mail 4.2 +From: Mary Smith +To: John Doe +Date: Fri, 10 Nov 1997 09:55:06 -0600 +Content-Type: message/rfc822; boundary="0042MSG" + +4.2 message summary + +--0042MSG +Content-Type: text/plain + +4.2.1 message text + +--0042MSG +Content-Type: multipart/alternative; boundary="0422ALTER" + +4.2.2 alternative summary + +--0422ALTER +Content-Type: text/plain + +4.2.2.1 plain text + +--0422ALTER +Content-Type: text/html + +

    4.2.2.2 html text

    + +--0422ALTER-- + +--0042MSG-- + +--0004ATTACH-- + +--0000MAIN-- + + +`, + }, + + {[]int{1}, + `Content-Type: text/plain + +1. main message + + +`, + `1. main message + + +`, + }, + {[]int{3}, + `Subject: Inside mail 3 +From: Mary Smith +To: John Doe +Date: Fri, 20 Nov 1997 09:55:06 -0600 +Content-Type: message/rfc822; boundary="0003MSG" + +3. message summary + +--0003MSG +Content-Type: text/plain + +3.1 message text + +--0003MSG +Content-Type: application/octet-stream +Content-Disposition: attachment; filename="msg_3_signature.sig" +Content-Transfer-Encoding: base64 + +3/2/MessageOctestStream/== + +--0003MSG-- + +`, + `3. message summary + +--0003MSG +Content-Type: text/plain + +3.1 message text + +--0003MSG +Content-Type: application/octet-stream +Content-Disposition: attachment; filename="msg_3_signature.sig" +Content-Transfer-Encoding: base64 + +3/2/MessageOctestStream/== + +--0003MSG-- + +`, + }, + {[]int{3, 1}, + `Content-Type: text/plain + +3.1 message text + +`, + `3.1 message text + +`, + }, + {[]int{3, 2}, + `Content-Type: application/octet-stream +Content-Disposition: attachment; filename="msg_3_signature.sig" +Content-Transfer-Encoding: base64 + +3/2/MessageOctestStream/== + +`, + `3/2/MessageOctestStream/== + +`, + }, + {[]int{4, 2, 2, 1}, + `Content-Type: text/plain + +4.2.2.1 plain text + +`, + `4.2.2.1 plain text + +`, + }, + {[]int{4, 2, 2, 2}, + `Content-Type: text/html + +

    4.2.2.2 html text

    + +`, + `

    4.2.2.2 html text

    + +`, + }, +} diff --git a/pkg/mime/Changelog.md b/pkg/mime/Changelog.md new file mode 100644 index 00000000..8cc4c32e --- /dev/null +++ b/pkg/mime/Changelog.md @@ -0,0 +1,24 @@ +# Do not modify this file! +It is here for historical reasons only. All changes should be documented in the +Changelog at the root of this repository. + + +# Changelog + +## [2019-12-10] v1.0.2 + +### Added +* support for shift_JIS (cp932) encoding + +## [2019-09-30] v1.0.1 + +### Changed +* fix divide by zero + +## [2019-09-26] v1.0.0 + +### Changed +* Import-Export#192: filter header parameters + * ignore twice the same parameter (take the latest) + * convert non utf8 RFC2231 parameters to a single line utf8 RFC2231 + diff --git a/pkg/mime/encoding.go b/pkg/mime/encoding.go new file mode 100644 index 00000000..b4e2c4ea --- /dev/null +++ b/pkg/mime/encoding.go @@ -0,0 +1,254 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmmime + +import ( + "bytes" + "fmt" + "io" + "mime" + "mime/quotedprintable" + "regexp" + "strings" + "unicode/utf8" + + "encoding/base64" + + "golang.org/x/text/encoding" + "golang.org/x/text/encoding/htmlindex" + "golang.org/x/text/transform" +) + +var wordDec = &mime.WordDecoder{ + CharsetReader: func(charset string, input io.Reader) (io.Reader, error) { + dec, err := selectDecoder(charset) + if err != nil { + return nil, err + } + if dec == nil { // utf-8 + return input, nil + } + return dec.Reader(input), nil + }, +} + +// Expects trimmed lowercase. +func getEncoding(charset string) (enc encoding.Encoding, err error) { + preparsed := strings.Trim(strings.ToLower(charset), " \t\r\n") + + // koi + re := regexp.MustCompile("(cs)?koi[-_ ]?8?[-_ ]?(r|ru|u|uk)?$") + matches := re.FindAllStringSubmatch(preparsed, -1) + if len(matches) == 1 && len(matches[0]) == 3 { + preparsed = "koi8-" + switch matches[0][2] { + case "u", "uk": + preparsed += "u" + default: + preparsed += "r" + } + } + + // windows-XXXX + re = regexp.MustCompile("(cp|(cs)?win(dows)?)[-_ ]?([0-9]{3,4})$") + matches = re.FindAllStringSubmatch(preparsed, -1) + if len(matches) == 1 && len(matches[0]) == 5 { + switch matches[0][4] { + case "874", "1250", "1251", "1252", "1253", "1254", "1255", "1256", "1257", "1258": + preparsed = "windows-" + matches[0][4] + } + } + + // iso + re = regexp.MustCompile("iso[-_ ]?([0-9]{4})[-_ ]?([0-9]+|jp)?[-_ ]?(i|e)?") + matches = re.FindAllStringSubmatch(preparsed, -1) + if len(matches) == 1 && len(matches[0]) == 4 { + if matches[0][1] == "2022" && matches[0][2] == "jp" { + preparsed = "iso-2022-jp" + } + if matches[0][1] == "8859" { + switch matches[0][2] { + case "1", "2", "3", "4", "5", "7", "8", "9", "10", "11", "13", "14", "15", "16": + preparsed = "iso-8859-" + matches[0][2] + if matches[0][3] == "i" { + preparsed += "-" + matches[0][3] + } + case "": + preparsed = "iso-8859-1" + } + } + } + + // Latin is tricky. + re = regexp.MustCompile("^(cs|csiso)?l(atin)?[-_ ]?([0-9]{1,2})$") + matches = re.FindAllStringSubmatch(preparsed, -1) + if len(matches) == 1 && len(matches[0]) == 4 { + switch matches[0][3] { + case "1": + preparsed = "windows-1252" + case "2", "3", "4", "5": + preparsed = "iso-8859-" + matches[0][3] + case "6": + preparsed = "iso-8859-10" + case "8": + preparsed = "iso-8859-14" + case "9": + preparsed = "iso-8859-15" + case "10": + preparsed = "iso-8859-16" + } + } + + // Missing substitutions. + switch preparsed { + case "csutf8", "iso-utf-8", "utf8mb4": + preparsed = "utf-8" + + case "cp932", "windows-932", "windows-31J", "ibm-943", "cp943": + preparsed = "shift_jis" + case "eucjp", "ibm-eucjp": + preparsed = "euc-jp" + case "euckr", "ibm-euckr", "cp949": + preparsed = "euc-kr" + case "euccn", "ibm-euccn": + preparsed = "gbk" + case "zht16mswin950", "cp950": + preparsed = "big5" + + case "csascii", + "ansi_x3.4-1968", + "ansi_x3.4-1986", + "ansi_x3.110-1983", + "cp850", + "cp858", + "us", + "iso646", + "iso-646", + "iso646-us", + "iso_646.irv:1991", + "cp367", + "ibm367", + "ibm-367", + "iso-ir-6": + preparsed = "ascii" + + case "ibm852": + preparsed = "iso-8859-2" + case "iso-ir-199", "iso-celtic": + preparsed = "iso-8859-14" + case "iso-ir-226": + preparsed = "iso-8859-16" + + case "macroman": + preparsed = "macintosh" + } + + enc, _ = htmlindex.Get(preparsed) + if enc == nil { + err = fmt.Errorf("can not get encodig for '%s' (or '%s')", charset, preparsed) + } + return +} + +func selectDecoder(charset string) (decoder *encoding.Decoder, err error) { + var enc encoding.Encoding + lcharset := strings.Trim(strings.ToLower(charset), " \t\r\n") + switch lcharset { + case "utf7", "utf-7", "unicode-1-1-utf-7": + return NewUtf7Decoder(), nil + default: + enc, err = getEncoding(lcharset) + } + if err == nil { + decoder = enc.NewDecoder() + } + return +} + +// DecodeHeader if needed. Returns error if raw contains non-utf8 characters. +func DecodeHeader(raw string) (decoded string, err error) { + if decoded, err = wordDec.DecodeHeader(raw); err != nil { + decoded = raw + } + if !utf8.ValidString(decoded) { + err = fmt.Errorf("header contains non utf8 chars: %v", err) + } + return +} + +// EncodeHeader using quoted printable and utf8 +func EncodeHeader(s string) string { + return mime.QEncoding.Encode("utf-8", s) +} + +// DecodeCharset decodes the orginal using content type parameters. +// When charset is missing it checks thaht the content is valid utf8. +func DecodeCharset(original []byte, contentTypeParams map[string]string) ([]byte, error) { + var decoder *encoding.Decoder + var err error + if charset, ok := contentTypeParams["charset"]; ok { + decoder, err = selectDecoder(charset) + } else { + if utf8.Valid(original) { + return original, nil + } + err = fmt.Errorf("non-utf8 content without charset specification") + } + + if err != nil { + return original, err + } + + utf8 := make([]byte, len(original)) + nDst, nSrc, err := decoder.Transform(utf8, original, false) + for err == transform.ErrShortDst { + if nDst < 1 { + nDst = 1 + } + if nSrc < 1 { + nSrc = 1 + } + utf8 = make([]byte, (nDst/nSrc+1)*len(original)) + nDst, nSrc, err = decoder.Transform(utf8, original, false) + } + if err != nil { + return original, err + } + utf8 = bytes.Trim(utf8, "\x00") + + return utf8, nil +} + +// DecodeContentEncoding wraps the reader with decoder based on content encoding. +func DecodeContentEncoding(r io.Reader, contentEncoding string) (d io.Reader) { + switch strings.ToLower(contentEncoding) { + case "quoted-printable": + d = quotedprintable.NewReader(r) + case "base64": + d = base64.NewDecoder(base64.StdEncoding, r) + case "7bit", "8bit", "binary", "": // Nothing to do + d = r + } + return +} + +// ParseMediaType from MIME doesn't support RFC2231 for non asci / utf8 encodings so we have to pre-parse it. +func ParseMediaType(v string) (mediatype string, params map[string]string, err error) { + v, _ = changeEncodingAndKeepLastParamDefinition(v) + return mime.ParseMediaType(v) +} diff --git a/pkg/mime/encoding_test.go b/pkg/mime/encoding_test.go new file mode 100644 index 00000000..85c492bf --- /dev/null +++ b/pkg/mime/encoding_test.go @@ -0,0 +1,445 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmmime + +import ( + "bytes" + //"fmt" + "strings" + "testing" + + "golang.org/x/text/encoding/htmlindex" + + a "github.com/stretchr/testify/assert" +) + +func TestDecodeHeader(t *testing.T) { + testData := []struct{ raw, expected string }{ + { + "", + "", + }, + { + "=?iso-2022-jp?Q?=1B$B!Z=1B(BTimes_Car_PLUS=1B$B![JV5Q>Z=1B(B?=", + "【Times Car PLUS】返却証", + }, + { + `=?iso-2022-jp?Q?iTunes_Movie_=1B$B%K%e!<%j%j!<%9$HCmL\:nIJ=1B(B?=`, + "iTunes Movie ニューリリースと注目作品", + }, + { + "=?UTF-8?B?w4TDi8OPw5bDnA==?= =?UTF-8?B?IMOkw6vDr8O2w7w=?=", + "ÄËÏÖÜ äëïöü", + }, + { + "=?ISO-8859-2?B?xMtJ1tw=?= =?ISO-8859-2?B?IOTrafb8?=", + "ÄËIÖÜ äëiöü", + }, + { + "=?uknown?B?xMtJ1tw=?= =?ISO-8859-2?B?IOTrafb8?=", + "=?uknown?B?xMtJ1tw=?= =?ISO-8859-2?B?IOTrafb8?=", + }, + } + + for _, val := range testData { + if decoded, err := DecodeHeader(val.raw); strings.Compare(val.expected, decoded) != 0 { + t.Errorf("Incorrect decoding of header %q expected %q but have %q; Error %v", val.raw, val.expected, decoded, err) + } else { + // fmt.Println("Header", val.raw, "successfully decoded", decoded, ". Error", err) + } + } +} + +type testParseMediaTypeData struct { + arg, wantMediaType string + wantParams map[string]string +} + +func (d *testParseMediaTypeData) run(t *testing.T) { + gotMediaType, params, err := ParseMediaType(d.arg) + a.Nil(t, err) + a.Equal(t, d.wantMediaType, gotMediaType) + a.Equal(t, d.wantParams, params) +} + +func TestParseMediaType(t *testing.T) { + testTable := map[string]testParseMediaTypeData{ + "TwiceTheSameParameter": { + arg: "attachment; filename=joy.txt; filename=JOY.TXT; title=hi;", + wantMediaType: "attachment", + wantParams: map[string]string{"filename": "JOY.TXT", "title": "hi"}, + }, + "SingleLineUTF8": { + arg: "attachment;\nfilename*=utf-8''%F0%9F%98%81%F0%9F%98%82.txt;\n title=smile", + wantMediaType: "attachment", + wantParams: map[string]string{"filename": "😁😂.txt", "title": "smile"}, + }, + "MultiLineUTF8": { + arg: "attachment;\nfilename*0*=utf-8''%F0%9F%98%81; title=smile;\nfilename*1*=%F0%9F%98%82;\nfilename*2=.txt", + wantMediaType: "attachment", + wantParams: map[string]string{"filename": "😁😂.txt", "title": "smile"}, + }, + "MultiLineFirstNoEncNextUTF8": { + arg: "attachment;\nfilename*0*=utf-8''joy ;\n title*=utf-8''smile; \nfilename*1*=%F0%9F%98%82;\nfilename*2=.txt", + wantMediaType: "attachment", + wantParams: map[string]string{"filename": "joy😂.txt", "title": "smile"}, + }, + "SingleLineBig5": { + arg: "attachment;\nfilename*=big5''%B3%C6%A7%D1%BF%FD.m4a; title*=utf8''memorandum", + wantMediaType: "attachment", + wantParams: map[string]string{"filename": "備忘錄.m4a", "title": "memorandum"}, + }, + "MultiLineBig5": { + arg: "attachment;\nfilename*0*=big5''%B3%C6a; title*0=utf8''memorandum; filename*2=%BF%FD.m4a; \nfilename*1*=%A7%D1b;", + wantMediaType: "attachment", + wantParams: map[string]string{"filename": "備a忘b錄.m4a", "title": "memorandum"}, + }, + } + for name, testData := range testTable { + t.Run(name, testData.run) + } +} + +func TestGetEncoding(t *testing.T) { + // All MIME charsets with aliases can be found here: + // https://www.iana.org/assignments/character-sets/character-sets.xhtml + mimesets := map[string][]string{ + "utf-8": []string{ // MIB 16 + "utf8", + "csutf8", + "unicode-1-1-utf-8", + "iso-utf-8", + "utf8mb4", + }, + "gbk": []string{ + "gb2312", // MIB 2025 + //"euc-cn": []string{ + "euccn", + "ibm-euccn", + }, + //"utf7": []string{"utf-7", "unicode-1-1-utf-7"}, + "iso-8859-2": []string{ // MIB 5 + "iso-ir-101", + "iso_8859-2", + "iso8859-2", + "latin2", + "l2", + "csisolatin2", + "ibm852", + //"FAILEDibm852", + }, + "iso-8859-3": []string{ // MIB 6 + "iso-ir-109", + "iso_8859-3", + "latin3", + "l3", + "csisolatin3", + }, + "iso-8859-4": []string{ // MIB 7 + "iso-ir-110", + "iso_8859-4", + "latin4", + "l4", + "csisolatin4", + }, + "iso-8859-5": []string{ // MIB 8 + "iso-ir-144", + "iso_8859-5", + "cyrillic", + "csisolatincyrillic", + }, + "iso-8859-6": []string{ // MIB 9 + "iso-ir-127", + "iso_8859-6", + "ecma-114", + "asmo-708", + "arabic", + "csisolatinarabic", + //"iso-8859-6e": []string{ // MIB 81 just direction + "csiso88596e", + "iso-8859-6-e", + //"iso-8859-6i": []string{ // MIB 82 + "csiso88596i", + "iso-8859-6-i"}, + "iso-8859-7": []string{ // MIB 10 + "iso-ir-126", + "iso_8859-7", + "elot_928", + "ecma-118", + "greek", + "greek8", + "csisolatingreek"}, + "iso-8859-8": []string{ // MIB 11 + "iso-ir-138", + "iso_8859-8", + "hebrew", + "csisolatinhebrew", + //"iso-8859-8e": []string{ // MIB 84 (directionality + "csiso88598e", + "iso-8859-8-e", + }, + "iso-8859-8-i": []string{ // MIB 85 + "logical", + "csiso88598i", + "iso-8859-8-i", // Hebrew, the "i" means right-to-left, probably unnecessary with ISO cleaning above. + }, + "iso-8859-10": []string{ // MIB 13 + "iso-ir-157", + "l6", + "iso_8859-10:1992", + "csisolatin6", + "latin6"}, + "iso-8859-13": []string{ // MIB 109 + "csiso885913"}, + "iso-8859-14": []string{ // MIB 110 + "iso-ir-199", + "iso_8859-14:1998", + "iso_8859-14", + "latin8", + "iso-celtic", + "l8", + "csiso885914"}, + "iso-8859-15": []string{ // MIB 111 + "iso_8859-15", + "latin-9", + "csiso885915", + "ISO8859-15"}, + "iso-8859-16": []string{ // MIB 112 + "iso-ir-226", + "iso_8859-16:2001", + "iso_8859-16", + "latin10", + "l10", + "csiso885916", + }, + "windows-874": []string{ // MIB 2109 + "cswindows874", + "cp874", + "iso-8859-11", + "tis-620", + }, + "windows-1250": []string{ // MIB 2250 + "cswindows1250", + "cp1250", + }, + "windows-1251": []string{ // MIB 2251 + "cswindows1251", + "cp1251", + }, + "windows-1252": []string{ // MIB 2252 + "cswindows1252", + "cp1252", + "3dwindows-1252", + "we8mswin1252", + "us-ascii", // MIB 3 + "ansi_x3.110-1983", // MIB 74 // usascii + //"iso-8859-1": []string{ // MIB 4 succeed by win1252 + "iso8859-1", + "iso-ir-100", + "iso_8859-1", + "latin1", + "l1", + "ibm819", + "cp819", + "csisolatin1", + "ansi_x3.4-1968", + "ansi_x3.4-1986", + "cp850", + "cp858", // "cp850" Mostly correct except for the Euro sign. + "iso_646.irv:1991", + "iso646-us", + "us", + "ibm367", + "cp367", + "csascii", + "ascii", + "iso-ir-6", + "we8iso8859p1", + }, + "windows-1253": []string{"cswindows1253", "cp1253"}, // MIB 2253 + "windows-1254": []string{"cswindows1254", "cp1254"}, // MIB 2254 + "windows-1255": []string{"cSwindows1255", "cp1255"}, // MIB 2255 + "windows-1256": []string{"cswIndows1256", "cp1256"}, // MIB 2256 + "windows-1257": []string{"cswinDows1257", "cp1257"}, // MIB 2257 + "windows-1258": []string{"cswindoWs1258", "cp1258"}, // MIB 2257 + "koi8-r": []string{"cskoi8r", "koi8r"}, // MIB 2084 + "koi8-u": []string{"cskoi8u", "koi8u"}, // MIB 2088 + "macintosh": []string{"mac", "macroman", "csmacintosh"}, // MIB 2027 + "big5": []string{ + "zht16mswin950", // cp950 + "cp950", + }, + "euc-kr": []string{ + "euckr", // MIB 38 + "ibm-euckr", + //"uhc": []string{ // Korea + "ks_c_5601-1987", + "ksc5601", + "cp949", + }, + "euc-jp": []string{ + "eucjp", + "ibm-eucjp", + }, + "shift_jis": []string{ + "CP932", + "MS932", + "Windows-932", + "Windows-31J", + "MS_Kanji", + "IBM-943", + "CP943", + }, + "iso-2022-jp": []string{ // MIB 39 + "iso2022jp", + "csiso2022jp", + }, + } + + for expected, names := range mimesets { + expenc, _ := htmlindex.Get(expected) + if canonical, err := htmlindex.Name(expenc); canonical != expected || err != nil { + t.Fatalf("Error while get canonical name. Expected '%v' but have %v `%#v`: %v", expected, canonical, expenc, err) + } + for _, name := range names { + enc, err := getEncoding(name) + if err != nil || enc == nil { + t.Errorf("Error while getting encoding for %v returned: '%#v' and error: '%v'", name, enc, err) + } + if expenc != enc { + t.Errorf("For %v expected %v '%v' but have '%v'", name, expected, expenc, enc) + } + } + } +} + +// sample text for UTF8 http://www.columbia.edu/~fdc/utf8/index.html +func TestEncodeReader(t *testing.T) { + // define test data + testData := []struct { + params map[string]string + original []byte + message string + }{ + // russian + { + map[string]string{"charset": "koi8-r"}, + // а, з, б, у, к, а, а, б, в, г, д, е, ё + []byte{0xC1, 0xDA, 0xC2, 0xD5, 0xCB, 0xC1, 0xC1, 0xC2, 0xD7, 0xC7, 0xC4, 0xC5, 0xA3}, + "азбукаабвгдеё", + }, + { + map[string]string{"charset": "KOI8-R"}, + []byte{0xC1, 0xDA, 0xC2, 0xD5, 0xCB, 0xC1, 0xC1, 0xC2, 0xD7, 0xC7, 0xC4, 0xC5, 0xA3}, + "азбукаабвгдеё", + }, + { + map[string]string{"charset": "csKOI8R"}, + []byte{0xC1, 0xDA, 0xC2, 0xD5, 0xCB, 0xC1, 0xC1, 0xC2, 0xD7, 0xC7, 0xC4, 0xC5, 0xA3}, + "азбукаабвгдеё", + }, + { + map[string]string{"charset": "koi8-u"}, + []byte{0xC1, 0xDA, 0xC2, 0xD5, 0xCB, 0xC1, 0xC1, 0xC2, 0xD7, 0xC7, 0xC4, 0xC5, 0xA3}, + "азбукаабвгдеё", + }, + { + map[string]string{"charset": "iso-8859-5"}, + // а , з , б , у , к , а , а , б , в , г , д , е , ё + []byte{0xD0, 0xD7, 0xD1, 0xE3, 0xDA, 0xD0, 0xD0, 0xD1, 0xD2, 0xD3, 0xD4, 0xD5, 0xF1}, + "азбукаабвгдеё", + }, + { + map[string]string{"charset": "csWrong"}, + []byte{0xD0, 0xD7, 0xD1, 0xE3, 0xDA, 0xD0, 0xD0, 0xD1, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6}, + "", + }, + { + map[string]string{"charset": "utf8"}, + []byte{0xD0, 0xB0, 0xD0, 0xB7, 0xD0, 0xB1, 0xD1, 0x83, 0xD0, 0xBA, 0xD0, 0xB0, 0xD0, 0xB0, 0xD0, 0xB1, 0xD0, 0xB2, 0xD0, 0xB3, 0xD0, 0xB4, 0xD0, 0xB5, 0xD1, 0x91}, + "азбукаабвгдеё", + }, + // czechoslovakia + { + map[string]string{"charset": "windows-1250"}, + []byte{225, 228, 232, 233, 236, 244}, + "áäčéěô", + }, + // umlauts + { + map[string]string{"charset": "iso-8859-1"}, + []byte{196, 203, 214, 220, 228, 235, 246, 252}, + "ÄËÖÜäëöü", + }, + // latvia + { + map[string]string{"charset": "iso-8859-4"}, + []byte{224, 239, 243, 182, 254}, + "āīķļū", + }, + { // encoded by https://www.motobit.com/util/charset-codepage-conversion.asp + map[string]string{"charset": "utf7"}, + []byte("He wes Leovena+APA-es sone -- li+APA-e him be Drihten.+A6QDtw- +A7MDuwPOA8MDwwOx- +A7wDvwPF- +A60DtAPJA8MDsQO9- +A7UDuwO7A7cDvQO5A7oDrg-. +BCcENQRABD0ENQQ7BDg- +BDgENwQxBEs- +BDcENAQ1BEEETA- +BDg- +BEIEMAQ8-,+BCcENQRABD0ENQQ7BDg- +BDgENwQxBEs- +BDcENAQ1BEEETA- +BDg- +BEIEMAQ8-,+C68LvguuC7ELvwuoC80LpA- +C64Lygu0C78LlQuzC78LsgvH- +C6QLrgu/C7QLzQuuC8oLtAu/- +C6oLywuyC80- +C4cLqQu/C6QLvgu1C6QLwQ- +C44LmQvNC5ULwQuuC80- +C5ULvgujC8sLrgvN-."), + "He wes Leovenaðes sone -- liðe him be Drihten.Τη γλώσσα μου έδωσαν ελληνική. Чернели избы здесь и там,Чернели избы здесь и там,யாமறிந்த மொழிகளிலே தமிழ்மொழி போல் இனிதாவது எங்கும் காணோம்.", + }, + + // iconv -f UTF8 -t GB2312 utf8.txt | hexdump -v -e '"0x" 1/1 "%x, "' + { // encoded by iconv; dump by `cat gb2312.txt | hexdump -v -e '"0x" 1/1 "%x "'` and reformat; text from https://zh.wikipedia.org/wiki/GB_2312 + map[string]string{"charset": "GB2312"}, + []byte{0x47, 0x42, 0x20, 0x32, 0x33, 0x31, 0x32, 0xb5, 0xc4, 0xb3, 0xf6, 0xcf, 0xd6, 0xa3, 0xac, 0xbb, 0xf9, 0xb1, 0xbe, 0xc2, 0xfa, 0xd7, 0xe3, 0xc1, 0xcb, 0xba, 0xba, 0xd7, 0xd6, 0xb5, 0xc4, 0xbc, 0xc6, 0xcb, 0xe3, 0xbb, 0xfa, 0xb4, 0xa6, 0xc0, 0xed, 0xd0, 0xe8, 0xd2, 0xaa, 0xa3, 0xac, 0xcb, 0xfc, 0xcb, 0xf9, 0xca, 0xd5, 0xc2, 0xbc, 0xb5, 0xc4, 0xba, 0xba, 0xd7, 0xd6, 0xd2, 0xd1, 0xbe, 0xad, 0xb8, 0xb2, 0xb8, 0xc7, 0xd6, 0xd0, 0xb9, 0xfa, 0xb4, 0xf3, 0xc2, 0xbd, 0x39, 0x39, 0x2e, 0x37, 0x35, 0x25, 0xb5, 0xc4, 0xca, 0xb9, 0xd3, 0xc3, 0xc6, 0xb5, 0xc2, 0xca, 0xa1, 0xa3, 0xb5, 0xab, 0xb6, 0xd4, 0xd3, 0xda, 0xc8, 0xcb, 0xc3, 0xfb}, + "GB 2312的出现,基本满足了汉字的计算机处理需要,它所收录的汉字已经覆盖中国大陆99.75%的使用频率。但对于人名", + }, + + { // encoded by iconv; text from https://jp.wikipedia.org/wiki/Shift_JIS + map[string]string{"charset": "shift-jis"}, + []byte{0x95, 0xb6, 0x8e, 0x9a, 0x95, 0x84, 0x8d, 0x86, 0x89, 0xbb, 0x95, 0xfb, 0x8e, 0xae, 0x53, 0x68, 0x69, 0x66, 0x74, 0x5f, 0x4a, 0x49, 0x53, 0x82, 0xcc, 0x90, 0xdd, 0x8c, 0x76, 0x8e, 0xd2, 0x82, 0xe7, 0x82, 0xcd, 0x81, 0x41, 0x90, 0xe6, 0x8d, 0x73, 0x82, 0xb5, 0x82, 0xc4, 0x82, 0xe6, 0x82, 0xad, 0x97, 0x98, 0x97, 0x70, 0x82, 0xb3, 0x82, 0xea, 0x82, 0xc4, 0x82, 0xa2, 0x82, 0xbd, 0x4a, 0x49, 0x53, 0x20, 0x43, 0x20, 0x36, 0x32, 0x32, 0x30, 0x81, 0x69, 0x8c, 0xbb, 0x8d, 0xdd, 0x82, 0xcc, 0x4a, 0x49, 0x53, 0x20, 0x58, 0x20, 0x30, 0x32, 0x30, 0x31, 0x81, 0x6a, 0x82, 0xcc, 0x38, 0x83, 0x72, 0x83, 0x62, 0x83, 0x67, 0x95, 0x84, 0x8d, 0x86, 0x81, 0x69, 0x88, 0xc8, 0x89, 0xba, 0x81, 0x75, 0x89, 0x70, 0x90, 0x94, 0x8e, 0x9a, 0x81, 0x45, 0x94, 0xbc, 0x8a, 0x70, 0x83, 0x4a, 0x83, 0x69, 0x81, 0x76, 0x81, 0x6a, 0x82, 0xc6, 0x81, 0x41, 0x4a, 0x49, 0x53, 0x20, 0x43, 0x20, 0x36, 0x32, 0x32, 0x36, 0x81, 0x69, 0x8c, 0xbb, 0x8d, 0xdd, 0x82, 0xcc, 0x4a, 0x49, 0x53, 0x20, 0x58, 0x20, 0x30, 0x32, 0x30, 0x38, 0x81, 0x41, 0x88, 0xc8, 0x89, 0xba, 0x81, 0x75, 0x8a, 0xbf, 0x8e, 0x9a, 0x81, 0x76, 0x81, 0x6a, 0x82, 0xcc, 0x97, 0xbc, 0x95, 0xb6, 0x8e, 0x9a, 0x8f, 0x57, 0x8d, 0x87, 0x82, 0xf0, 0x95, 0x5c, 0x8c, 0xbb, 0x82, 0xb5, 0x82, 0xe6, 0x82, 0xa4, 0x82, 0xc6, 0x82, 0xb5, 0x82, 0xbd, 0x81, 0x42, 0x82, 0xdc, 0x82, 0xbd, 0x81, 0x41, 0x83, 0x74, 0x83, 0x40, 0x83, 0x43, 0x83, 0x8b, 0x82, 0xcc, 0x91, 0xe5, 0x82, 0xab, 0x82, 0xb3, 0x82, 0xe2, 0x8f, 0x88, 0x97, 0x9d, 0x8e, 0x9e, 0x8a, 0xd4, 0x82, 0xcc, 0x92, 0x5a, 0x8f, 0x6b, 0x82, 0xf0, 0x90, 0x7d, 0x82, 0xe9, 0x82, 0xbd, 0x82, 0xdf, 0x81, 0x41, 0x83, 0x47, 0x83, 0x58, 0x83, 0x50, 0x81, 0x5b, 0x83, 0x76, 0x83, 0x56, 0x81, 0x5b, 0x83, 0x50, 0x83, 0x93, 0x83, 0x58, 0x82, 0xc8, 0x82, 0xb5, 0x82, 0xc5, 0x8d, 0xac, 0x8d, 0xdd, 0x89, 0xc2, 0x94, 0x5c, 0x82, 0xc9, 0x82, 0xb7, 0x82, 0xe9, 0x82, 0xb1, 0x82, 0xc6, 0x82, 0xf0, 0x8a, 0xe9, 0x90, 0x7d, 0x82, 0xb5, 0x82, 0xbd, 0x81, 0x42}, + "文字符号化方式Shift_JISの設計者らは、先行してよく利用されていたJIS C 6220(現在のJIS X 0201)の8ビット符号(以下「英数字・半角カナ」)と、JIS C 6226(現在のJIS X 0208、以下「漢字」)の両文字集合を表現しようとした。また、ファイルの大きさや処理時間の短縮を図るため、エスケープシーケンスなしで混在可能にすることを企図した。", + }, + + // add more from mutations of https://en.wikipedia.org/wiki/World_Wide_Web + + } + + // run tests + for _, val := range testData { + //fmt.Println("Testing ", val) + expected := []byte(val.message) + decoded, err := DecodeCharset(val.original, val.params) + if len(expected) == 0 { + if err == nil { + t.Error("Expected err but have ", err) + } else { + //fmt.Println("Expected err: ", err) + continue + } + } else { + if err != nil { + t.Error("Expected ok but have ", err) + } + } + + if bytes.Equal(decoded, expected) { + // fmt.Println("Succesfull decoding of ", val.params, ":", string(decoded)) + } else { + t.Error("Wrong encoding of ", val.params, ".Expected\n", expected, "\nbut have\n", decoded) + } + if strings.Compare(val.message, string(decoded)) != 0 { + t.Error("Wrong message for ", val.params, ".Expected\n", val.message, "\nbut have\n", string(decoded)) + } + } +} diff --git a/pkg/mime/mediaType.go b/pkg/mime/mediaType.go new file mode 100644 index 00000000..9cdc9f76 --- /dev/null +++ b/pkg/mime/mediaType.go @@ -0,0 +1,364 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmmime + +import ( + "errors" + "fmt" + "strings" + "unicode" + + "github.com/sirupsen/logrus" +) + +// changeEncodingAndKeepLastParamDefinition is necessary to modify behaviour +// provided by the golang standard libraries. +func changeEncodingAndKeepLastParamDefinition(v string) (out string, err error) { + log := logrus.WithField("pkg", "pm-mime") + + out = v // By default don't do anything with that. + keepOrig := true + + i := strings.Index(v, ";") + if i == -1 { + i = len(v) + } + mediatype := strings.TrimSpace(strings.ToLower(v[0:i])) + + params := map[string]string{} + var continuation map[string]map[string]string + + v = v[i:] + for len(v) > 0 { + v = strings.TrimLeftFunc(v, unicode.IsSpace) + if len(v) == 0 { + break + } + key, value, rest := consumeMediaParam(v) + if key == "" { + break + } + + pmap := params + if idx := strings.Index(key, "*"); idx != -1 { + baseName := key[:idx] + if continuation == nil { + continuation = make(map[string]map[string]string) + } + var ok bool + if pmap, ok = continuation[baseName]; !ok { + continuation[baseName] = make(map[string]string) + pmap = continuation[baseName] + } + if isFirstContinuation(key) { + charset, _, err := get2231Charset(value) + if err != nil { + log.Errorln("Filter params:", err) + continue + } + if charset != "utf-8" && charset != "us-ascii" { + keepOrig = false + } + } + } + if _, exists := pmap[key]; exists { + keepOrig = false + } + pmap[key] = value + v = rest + } + + if keepOrig { + return + } + + if continuation != nil { + for paramKey, contMap := range continuation { + value, err := mergeContinuations(paramKey, contMap) + if err == nil { + params[paramKey+"*"] = value + continue + } + + // Fallback. + log.Errorln("Merge param", paramKey, ":", err) + for ck, cv := range contMap { + params[ck] = cv + } + } + } + + // Merge ; + out = mediatype + for k, v := range params { + out += ";" + out += k + out += "=" + out += v + } + + return +} + +func isFirstContinuation(key string) bool { + if idx := strings.Index(key, "*"); idx != -1 { + return key[idx:] == "*" || key[idx:] == "*0*" + } + return false +} + +// get2231Charset partially from mime/mediatype.go:211 function `decode2231Enc`. +func get2231Charset(v string) (charset, value string, err error) { + sv := strings.SplitN(v, "'", 3) + if len(sv) != 3 { + err = errors.New("incorrect RFC2231 charset format") + return + } + charset = strings.ToLower(sv[0]) + value = sv[2] + return +} + +func mergeContinuations(paramKey string, contMap map[string]string) (string, error) { + var err error + var charset, value string + + // Single value. + if contValue, ok := contMap[paramKey+"*"]; ok { + if charset, value, err = get2231Charset(contValue); err != nil { + return "", err + } + } else { + for n := 0; ; n++ { + contKey := fmt.Sprintf("%s*%d", paramKey, n) + contValue, isLast := contMap[contKey] + if !isLast { + var ok bool + contValue, ok = contMap[contKey+"*"] + if !ok { + return "", errors.New("not valid RFC2231 continuation") + } + } + if n == 0 { + if charset, value, err = get2231Charset(contValue); err != nil || charset == "" { + return "", err + } + } else { + value += contValue + } + if isLast { + break + } + } + } + + return convertHexToUTF(charset, value) +} + +// convertHexToUTF converts hex values string with charset to UTF8 in RFC2231 format. +func convertHexToUTF(charset, value string) (string, error) { + raw, err := percentHexUnescape(value) + if err != nil { + return "", err + } + utf8, err := DecodeCharset(raw, map[string]string{"charset": charset}) + return "utf-8''" + percentHexEscape(utf8), err +} + +// consumeMediaParam copy paste mime/mediatype.go:297. +func consumeMediaParam(v string) (param, value, rest string) { + rest = strings.TrimLeftFunc(v, unicode.IsSpace) + if !strings.HasPrefix(rest, ";") { + return "", "", v + } + + rest = rest[1:] // Consume semicolon. + rest = strings.TrimLeftFunc(rest, unicode.IsSpace) + param, rest = consumeToken(rest) + param = strings.ToLower(param) + if param == "" { + return "", "", v + } + + rest = strings.TrimLeftFunc(rest, unicode.IsSpace) + if !strings.HasPrefix(rest, "=") { + return "", "", v + } + rest = rest[1:] // Consume equals sign. + rest = strings.TrimLeftFunc(rest, unicode.IsSpace) + value, rest2 := consumeValue(rest) + if value == "" && rest2 == rest { + return "", "", v + } + rest = rest2 + return param, value, rest +} + +// consumeToken copy paste mime/mediatype.go:238. +// consumeToken consumes a token from the beginning of the provided string, +// per RFC 2045 section 5.1 (referenced from 2183), and returns +// the token consumed and the rest of the string. +// Returns ("", v) on failure to consume at least one character. +func consumeToken(v string) (token, rest string) { + notPos := strings.IndexFunc(v, isNotTokenChar) + if notPos == -1 { + return v, "" + } + if notPos == 0 { + return "", v + } + return v[0:notPos], v[notPos:] +} + +// consumeValue copy paste mime/mediatype.go:253 +// consumeValue consumes a "value" per RFC 2045, where a value is +// either a 'token' or a 'quoted-string'. On success, consumeValue +// returns the value consumed (and de-quoted/escaped, if a +// quoted-string) and the rest of the string. +// On failure, returns ("", v). +func consumeValue(v string) (value, rest string) { + if v == "" { + return + } + if v[0] != '"' { + return consumeToken(v) + } + + // parse a quoted-string + buffer := new(strings.Builder) + for i := 1; i < len(v); i++ { + r := v[i] + if r == '"' { + return buffer.String(), v[i+1:] + } + // When MSIE sends a full file path (in "intranet mode"), it does not + // escape backslashes: "C:\dev\go\foo.txt", not "C:\\dev\\go\\foo.txt". + // + // No known MIME generators emit unnecessary backslash escapes + // for simple token characters like numbers and letters. + // + // If we see an unnecessary backslash escape, assume it is from MSIE + // and intended as a literal backslash. This makes Go servers deal better + // with MSIE without affecting the way they handle conforming MIME + // generators. + if r == '\\' && i+1 < len(v) && !isTokenChar(rune(v[i+1])) { + buffer.WriteByte(v[i+1]) + i++ + continue + } + if r == '\r' || r == '\n' { + return "", v + } + buffer.WriteByte(v[i]) + } + // Did not find end quote. + return "", v +} + +// isNotTokenChar copy paste from mime/mediatype.go:234. +func isNotTokenChar(r rune) bool { + return !isTokenChar(r) +} + +// isTokenChar copy paste from mime/grammar.go:19. +// isTokenChar reports whether rune is in 'token' as defined by RFC 1521 and RFC 2045. +func isTokenChar(r rune) bool { + // token := 1* + return r > 0x20 && r < 0x7f && !isTSpecial(r) +} + +// isTSpecial copy paste from mime/grammar.go:13 +// isTSpecial reports whether rune is in 'tspecials' as defined by RFC +// 1521 and RFC 2045. +func isTSpecial(r rune) bool { + return strings.ContainsRune(`()<>@,;:\"/[]?=`, r) +} + +func percentHexEscape(raw []byte) (out string) { + for _, v := range raw { + out += fmt.Sprintf("%%%x", v) + } + return +} + +// percentHexUnescape copy paste from mime/mediatype.go:325. +func percentHexUnescape(s string) ([]byte, error) { + // Count %, check that they're well-formed. + percents := 0 + for i := 0; i < len(s); { + if s[i] != '%' { + i++ + continue + } + percents++ + if i+2 >= len(s) || !ishex(s[i+1]) || !ishex(s[i+2]) { + s = s[i:] + if len(s) > 3 { + s = s[0:3] + } + return []byte{}, fmt.Errorf("mime: bogus characters after %%: %q", s) + } + i += 3 + } + if percents == 0 { + return []byte(s), nil + } + + t := make([]byte, len(s)-2*percents) + j := 0 + for i := 0; i < len(s); { + switch s[i] { + case '%': + t[j] = unhex(s[i+1])<<4 | unhex(s[i+2]) + j++ + i += 3 + default: + t[j] = s[i] + j++ + i++ + } + } + return t, nil +} + +// ishex copy paste from mime/mediatype.go:364. +func ishex(c byte) bool { + switch { + case '0' <= c && c <= '9': + return true + case 'a' <= c && c <= 'f': + return true + case 'A' <= c && c <= 'F': + return true + } + return false +} + +// unhex copy paste from mime/mediatype.go:376. +func unhex(c byte) byte { + switch { + case '0' <= c && c <= '9': + return c - '0' + case 'a' <= c && c <= 'f': + return c - 'a' + 10 + case 'A' <= c && c <= 'F': + return c - 'A' + 10 + } + return 0 +} diff --git a/pkg/mime/parser.go b/pkg/mime/parser.go new file mode 100644 index 00000000..af03337e --- /dev/null +++ b/pkg/mime/parser.go @@ -0,0 +1,544 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmmime + +import ( + "bufio" + "bytes" + "io" + "io/ioutil" + "mime" + "mime/multipart" + "net/http" + "net/mail" + "net/textproto" + "regexp" + "strings" + + log "github.com/sirupsen/logrus" +) + +// VisitAcceptor decides what to do with part which is processed. +// It is used by MIMEVisitor. +type VisitAcceptor interface { + Accept(partReader io.Reader, header textproto.MIMEHeader, hasPlainSibling bool, isFirst, isLast bool) (err error) +} + +func VisitAll(part io.Reader, h textproto.MIMEHeader, accepter VisitAcceptor) (err error) { + mediaType, _, err := getContentType(h) + if err != nil { + return + } + return accepter.Accept(part, h, mediaType == "text/plain", true, true) +} + +func IsLeaf(h textproto.MIMEHeader) bool { + return !strings.HasPrefix(h.Get("Content-Type"), "multipart/") +} + +// MIMEVisitor is main object to parse (visit) and process (accept) all parts of MIME message. +type MimeVisitor struct { + target VisitAcceptor +} + +// Accept reads part recursively if needed. +// hasPlainSibling is there when acceptor want to check alternatives. +func (mv *MimeVisitor) Accept(part io.Reader, h textproto.MIMEHeader, hasPlainSibling bool, isFirst, isLast bool) (err error) { + if !isFirst { + return + } + + parentMediaType, params, err := getContentType(h) + if err != nil { + return + } + + if err = mv.target.Accept(part, h, hasPlainSibling, true, false); err != nil { + return + } + + if !IsLeaf(h) { + var multiparts []io.Reader + var multipartHeaders []textproto.MIMEHeader + if multiparts, multipartHeaders, err = GetMultipartParts(part, params); err != nil { + return + } + hasPlainChild := false + for _, header := range multipartHeaders { + mediaType, _, _ := getContentType(header) + if mediaType == "text/plain" { + hasPlainChild = true + } + } + if hasPlainSibling && parentMediaType == "multipart/related" { + hasPlainChild = true + } + + for i, p := range multiparts { + if err = mv.Accept(p, multipartHeaders[i], hasPlainChild, true, true); err != nil { + return + } + if err = mv.target.Accept(part, h, hasPlainSibling, false, i == (len(multiparts)-1)); err != nil { + return + } + } + } + return +} + +// NewMIMEVisitor returns a new mime visitor initialised with an acceptor. +func NewMimeVisitor(targetAccepter VisitAcceptor) *MimeVisitor { + return &MimeVisitor{targetAccepter} +} + +func GetRawMimePart(rawdata io.Reader, boundary string) (io.Reader, io.Reader) { + b, _ := ioutil.ReadAll(rawdata) + tee := bytes.NewReader(b) + + reader := bufio.NewReader(bytes.NewReader(b)) + byteBoundary := []byte(boundary) + bodyBuffer := &bytes.Buffer{} + for { + line, _, err := reader.ReadLine() + if err != nil { + return tee, bytes.NewReader(bodyBuffer.Bytes()) + } + if bytes.HasPrefix(line, byteBoundary) { + break + } + } + lineEndingLength := 0 + for { + line, isPrefix, err := reader.ReadLine() + if err != nil { + return tee, bytes.NewReader(bodyBuffer.Bytes()) + } + if bytes.HasPrefix(line, byteBoundary) { + break + } + lineEndingLength = 0 + bodyBuffer.Write(line) + if !isPrefix { + reader.UnreadByte() + reader.UnreadByte() + token, _ := reader.ReadByte() + if token == '\r' { + lineEndingLength++ + bodyBuffer.WriteByte(token) + } + lineEndingLength++ + bodyBuffer.WriteByte(token) + } + } + ioutil.ReadAll(reader) + data := bodyBuffer.Bytes() + return tee, bytes.NewReader(data[0 : len(data)-lineEndingLength]) +} + +func GetAllChildParts(part io.Reader, h textproto.MIMEHeader) (parts []io.Reader, headers []textproto.MIMEHeader, err error) { + mediaType, params, err := getContentType(h) + if err != nil { + return + } + if strings.HasPrefix(mediaType, "multipart/") { + var multiparts []io.Reader + var multipartHeaders []textproto.MIMEHeader + if multiparts, multipartHeaders, err = GetMultipartParts(part, params); err != nil { + return + } + if strings.Contains(mediaType, "alternative") { + var chosenPart io.Reader + var chosenHeader textproto.MIMEHeader + if chosenPart, chosenHeader, err = pickAlternativePart(multiparts, multipartHeaders); err != nil { + return + } + var childParts []io.Reader + var childHeaders []textproto.MIMEHeader + if childParts, childHeaders, err = GetAllChildParts(chosenPart, chosenHeader); err != nil { + return + } + parts = append(parts, childParts...) + headers = append(headers, childHeaders...) + } else { + for i, p := range multiparts { + var childParts []io.Reader + var childHeaders []textproto.MIMEHeader + if childParts, childHeaders, err = GetAllChildParts(p, multipartHeaders[i]); err != nil { + return + } + parts = append(parts, childParts...) + headers = append(headers, childHeaders...) + } + } + } else { + parts = append(parts, part) + headers = append(headers, h) + } + return +} + +func GetMultipartParts(r io.Reader, params map[string]string) (parts []io.Reader, headers []textproto.MIMEHeader, err error) { + mr := multipart.NewReader(r, params["boundary"]) + parts = []io.Reader{} + headers = []textproto.MIMEHeader{} + var p *multipart.Part + for { + p, err = mr.NextPart() + if err == io.EOF { + err = nil + break + } + if err != nil { + return + } + b, _ := ioutil.ReadAll(p) + buffer := bytes.NewBuffer(b) + + parts = append(parts, buffer) + headers = append(headers, p.Header) + } + return +} + +func pickAlternativePart(parts []io.Reader, headers []textproto.MIMEHeader) (part io.Reader, h textproto.MIMEHeader, err error) { + + for i, h := range headers { + mediaType, _, err := getContentType(h) + if err != nil { + continue + } + if strings.HasPrefix(mediaType, "multipart/") { + return parts[i], headers[i], nil + } + } + for i, h := range headers { + mediaType, _, err := getContentType(h) + if err != nil { + continue + } + if mediaType == "text/html" { + return parts[i], headers[i], nil + } + } + for i, h := range headers { + mediaType, _, err := getContentType(h) + if err != nil { + continue + } + if mediaType == "text/plain" { + return parts[i], headers[i], nil + } + } + + // If we get all the way here, part will be nil. + return +} + +// "Parse address comment" as defined in http://tools.wordtothewise.com/rfc/822 +// FIXME: Does not work for address groups. +// NOTE: This should be removed for go>1.10 (please check). +func parseAddressComment(raw string) string { + parsed := []string{} + for _, item := range regexp.MustCompile("[,;]").Split(raw, -1) { + re := regexp.MustCompile("[(][^)]*[)]") + comments := strings.Join(re.FindAllString(item, -1), " ") + comments = strings.Replace(comments, "(", "", -1) + comments = strings.Replace(comments, ")", "", -1) + withoutComments := re.ReplaceAllString(item, "") + addr, err := mail.ParseAddress(withoutComments) + if err != nil { + continue + } + if addr.Name == "" { + addr.Name = comments + } + parsed = append(parsed, addr.String()) + } + return strings.Join(parsed, ", ") +} + +func checkHeaders(headers []textproto.MIMEHeader) bool { + foundAttachment := false + + for i := 0; i < len(headers); i++ { + h := headers[i] + + mediaType, _, _ := getContentType(h) + + if !strings.HasPrefix(mediaType, "text/") { + foundAttachment = true + } else if foundAttachment { + // This means that there is a text part after the first attachment, + // so we will have to convert the body from plain->HTML. + return true + } + } + return false +} + +func decodePart(partReader io.Reader, header textproto.MIMEHeader) (decodedPart io.Reader) { + decodedPart = DecodeContentEncoding(partReader, header.Get("Content-Transfer-Encoding")) + if decodedPart == nil { + log.Warnf("Unsupported Content-Transfer-Encoding '%v'", header.Get("Content-Transfer-Encoding")) + decodedPart = partReader + } + return +} + +// Assume 'text/plain' if missing. +func getContentType(header textproto.MIMEHeader) (mediatype string, params map[string]string, err error) { + contentType := header.Get("Content-Type") + if contentType == "" { + contentType = "text/plain" + } + + return mime.ParseMediaType(contentType) +} + +// ===================== MIME Printer =================================== +// Simply print resulting MIME tree into text form. +// TODO move this to file mime_printer.go. + +type stack []string + +func (s stack) Push(v string) stack { + return append(s, v) +} +func (s stack) Pop() (stack, string) { + l := len(s) + return s[:l-1], s[l-1] +} +func (s stack) Peek() string { + return s[len(s)-1] +} + +type MIMEPrinter struct { + result *bytes.Buffer + boundaryStack stack +} + +func NewMIMEPrinter() (pd *MIMEPrinter) { + return &MIMEPrinter{ + result: bytes.NewBuffer([]byte("")), + boundaryStack: stack{}, + } +} + +func (pd *MIMEPrinter) Accept(partReader io.Reader, header textproto.MIMEHeader, hasPlainSibling bool, isFirst, isLast bool) (err error) { + if isFirst { + http.Header(header).Write(pd.result) + pd.result.Write([]byte("\n")) + if IsLeaf(header) { + pd.result.ReadFrom(partReader) + } else { + _, params, _ := getContentType(header) + boundary := params["boundary"] + pd.boundaryStack = pd.boundaryStack.Push(boundary) + pd.result.Write([]byte("\nThis is a multi-part message in MIME format.\n--" + boundary + "\n")) + } + } else { + if !isLast { + pd.result.Write([]byte("\n--" + pd.boundaryStack.Peek() + "\n")) + } else { + var boundary string + pd.boundaryStack, boundary = pd.boundaryStack.Pop() + pd.result.Write([]byte("\n--" + boundary + "--\n.\n")) + } + } + return nil +} + +func (pd *MIMEPrinter) String() string { + return pd.result.String() +} + +// ======================== PlainText Collector ========================= +// Collect contents of all non-attachment text/plain parts and return it as a string. +// TODO move this to file collector_plaintext.go. + +type PlainTextCollector struct { + target VisitAcceptor + plainTextContents *bytes.Buffer +} + +func NewPlainTextCollector(targetAccepter VisitAcceptor) *PlainTextCollector { + return &PlainTextCollector{ + target: targetAccepter, + plainTextContents: bytes.NewBuffer([]byte("")), + } +} + +func (ptc *PlainTextCollector) Accept(partReader io.Reader, header textproto.MIMEHeader, hasPlainSibling bool, isFirst, isLast bool) (err error) { + if isFirst { + if IsLeaf(header) { + mediaType, params, _ := getContentType(header) + disp, _, _ := mime.ParseMediaType(header.Get("Content-Disposition")) + if mediaType == "text/plain" && disp != "attachment" { + partData, _ := ioutil.ReadAll(partReader) + decodedPart := decodePart(bytes.NewReader(partData), header) + + if buffer, err := ioutil.ReadAll(decodedPart); err == nil { + buffer, err = DecodeCharset(buffer, params) + if err != nil { + log.Warnln("Decode charset error:", err) + return err + } + ptc.plainTextContents.Write(buffer) + } + + err = ptc.target.Accept(bytes.NewReader(partData), header, hasPlainSibling, isFirst, isLast) + return + } + } + } + err = ptc.target.Accept(partReader, header, hasPlainSibling, isFirst, isLast) + return +} + +func (ptc PlainTextCollector) GetPlainText() string { + return ptc.plainTextContents.String() +} + +// ======================== Body Collector ============== +// Collect contents of all non-attachment parts and return it as a string. +// TODO move this to file collector_body.go. + +type BodyCollector struct { + target VisitAcceptor + htmlBodyBuffer *bytes.Buffer + plainBodyBuffer *bytes.Buffer + htmlHeaderBuffer *bytes.Buffer + plainHeaderBuffer *bytes.Buffer + hasHtml bool +} + +func NewBodyCollector(targetAccepter VisitAcceptor) *BodyCollector { + return &BodyCollector{ + target: targetAccepter, + htmlBodyBuffer: bytes.NewBuffer([]byte("")), + plainBodyBuffer: bytes.NewBuffer([]byte("")), + htmlHeaderBuffer: bytes.NewBuffer([]byte("")), + plainHeaderBuffer: bytes.NewBuffer([]byte("")), + } +} + +func (bc *BodyCollector) Accept(partReader io.Reader, header textproto.MIMEHeader, hasPlainSibling bool, isFirst, isLast bool) (err error) { + // TODO: Collect html and plaintext - if there's html with plain sibling don't include plain/text. + if isFirst { + if IsLeaf(header) { + mediaType, params, _ := getContentType(header) + disp, _, _ := mime.ParseMediaType(header.Get("Content-Disposition")) + if disp != "attachment" { + partData, _ := ioutil.ReadAll(partReader) + decodedPart := decodePart(bytes.NewReader(partData), header) + if buffer, err := ioutil.ReadAll(decodedPart); err == nil { + buffer, err = DecodeCharset(buffer, params) + if err != nil { + log.Warnln("Decode charset error:", err) + return err + } + if mediaType == "text/html" { + bc.hasHtml = true + http.Header(header).Write(bc.htmlHeaderBuffer) + bc.htmlBodyBuffer.Write(buffer) + } else if mediaType == "text/plain" { + http.Header(header).Write(bc.plainHeaderBuffer) + bc.plainBodyBuffer.Write(buffer) + } + } + + err = bc.target.Accept(bytes.NewReader(partData), header, hasPlainSibling, isFirst, isLast) + return + } + } + } + err = bc.target.Accept(partReader, header, hasPlainSibling, isFirst, isLast) + return +} + +func (bc *BodyCollector) GetBody() (string, string) { + if bc.hasHtml { + return bc.htmlBodyBuffer.String(), "text/html" + } else { + return bc.plainBodyBuffer.String(), "text/plain" + } +} + +func (bc *BodyCollector) GetHeaders() string { + if bc.hasHtml { + return bc.htmlHeaderBuffer.String() + } else { + return bc.plainHeaderBuffer.String() + } +} + +// ======================== Attachments Collector ============== +// Collect contents of all attachment parts and return them as a string. +// TODO move this to file collector_attachment.go. + +type AttachmentsCollector struct { + target VisitAcceptor + attBuffers []string + attHeaders []string +} + +func NewAttachmentsCollector(targetAccepter VisitAcceptor) *AttachmentsCollector { + return &AttachmentsCollector{ + target: targetAccepter, + attBuffers: []string{}, + attHeaders: []string{}, + } +} + +func (ac *AttachmentsCollector) Accept(partReader io.Reader, header textproto.MIMEHeader, hasPlainSibling bool, isFirst, isLast bool) (err error) { + if isFirst { + if IsLeaf(header) { + mediaType, params, _ := getContentType(header) + disp, _, _ := mime.ParseMediaType(header.Get("Content-Disposition")) + if (mediaType != "text/html" && mediaType != "text/plain") || disp == "attachment" { + partData, _ := ioutil.ReadAll(partReader) + decodedPart := decodePart(bytes.NewReader(partData), header) + + if buffer, err := ioutil.ReadAll(decodedPart); err == nil { + buffer, err = DecodeCharset(buffer, params) + if err != nil { + log.Warnln("Decode charset error:", err) + return err + } + headerBuf := new(bytes.Buffer) + http.Header(header).Write(headerBuf) + ac.attHeaders = append(ac.attHeaders, headerBuf.String()) + ac.attBuffers = append(ac.attBuffers, string(buffer)) + } + + err = ac.target.Accept(bytes.NewReader(partData), header, hasPlainSibling, isFirst, isLast) + return + } + } + } + err = ac.target.Accept(partReader, header, hasPlainSibling, isFirst, isLast) + return +} + +func (ac AttachmentsCollector) GetAttachments() []string { + return ac.attBuffers +} + +func (ac AttachmentsCollector) GetAttHeaders() []string { + return ac.attHeaders +} diff --git a/pkg/mime/parser_test.go b/pkg/mime/parser_test.go new file mode 100644 index 00000000..497b722e --- /dev/null +++ b/pkg/mime/parser_test.go @@ -0,0 +1,228 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmmime + +import ( + "bytes" + "fmt" + + "io/ioutil" + "net/mail" + + "net/textproto" + "strings" + "testing" +) + +func minimalParse(mimeBody string) (readBody string, plainContents string, err error) { + mm, err := mail.ReadMessage(strings.NewReader(mimeBody)) + if err != nil { + return + } + + h := textproto.MIMEHeader(mm.Header) + mmBodyData, err := ioutil.ReadAll(mm.Body) + if err != nil { + return + } + + printAccepter := NewMIMEPrinter() + plainTextCollector := NewPlainTextCollector(printAccepter) + visitor := NewMimeVisitor(plainTextCollector) + err = VisitAll(bytes.NewReader(mmBodyData), h, visitor) + + readBody = printAccepter.String() + plainContents = plainTextCollector.GetPlainText() + + return readBody, plainContents, err +} + +func androidParse(mimeBody string) (body, headers string, atts, attHeaders []string, err error) { + mm, err := mail.ReadMessage(strings.NewReader(mimeBody)) + if err != nil { + return + } + + h := textproto.MIMEHeader(mm.Header) + mmBodyData, err := ioutil.ReadAll(mm.Body) + + printAccepter := NewMIMEPrinter() + bodyCollector := NewBodyCollector(printAccepter) + attachmentsCollector := NewAttachmentsCollector(bodyCollector) + mimeVisitor := NewMimeVisitor(attachmentsCollector) + err = VisitAll(bytes.NewReader(mmBodyData), h, mimeVisitor) + + body, _ = bodyCollector.GetBody() + headers = bodyCollector.GetHeaders() + atts = attachmentsCollector.GetAttachments() + attHeaders = attachmentsCollector.GetAttHeaders() + + return +} + +func TestParseBoundaryIsEmpty(t *testing.T) { + testMessage := + `Date: Sun, 10 Mar 2019 11:10:06 -0600 +In-Reply-To: +X-Original-To: enterprise@protonmail.com +References: +To: "ProtonMail" +X-Pm-Origin: external +Delivered-To: enterprise@protonmail.com +Content-Type: multipart/mixed; boundary=ac7e36bd45425e70b4dab2128f34172e4dc3f9ff2eeb47e909267d4252794ec7 +Reply-To: XYZ +Mime-Version: 1.0 +Subject: Encrypted Message +Return-Path: +From: XYZ +X-Pm-Conversationid-Id: gNX9bDPLmBgFZ-C3Tdlb628cas1Xl0m4dql5nsWzQAEI-WQv0ytfwPR4-PWELEK0_87XuFOgetc239Y0pjPYHQ== +X-Pm-Date: Sun, 10 Mar 2019 18:10:06 +0100 +Message-Id: <68c11e46-e611-d9e4-edc1-5ec96bac77cc@unicoderns.com> +X-Pm-Transfer-Encryption: TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits) +X-Pm-External-Id: <68c11e46-e611-d9e4-edc1-5ec96bac77cc@unicoderns.com> +X-Pm-Internal-Id: _iJ8ETxcqXTSK8IzCn0qFpMUTwvRf-xJUtldRA1f6yHdmXjXzKleG3F_NLjZL3FvIWVHoItTxOuuVXcukwwW3g== +Openpgp: preference=signencrypt +User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Thunderbird/60.4.0 +X-Pm-Content-Encryption: end-to-end + +--ac7e36bd45425e70b4dab2128f34172e4dc3f9ff2eeb47e909267d4252794ec7 +Content-Disposition: inline +Content-Transfer-Encoding: quoted-printable +Content-Type: multipart/mixed; charset=utf-8 + +Content-Type: multipart/mixed; boundary="xnAIW3Turb9YQZ2rXc2ZGZH45WepHIZyy"; + protected-headers="v1" +From: XYZ +To: "ProtonMail" +Subject: Encrypted Message +Message-ID: <68c11e46-e611-d9e4-edc1-5ec96bac77cc@unicoderns.com> +References: +In-Reply-To: + +--xnAIW3Turb9YQZ2rXc2ZGZH45WepHIZyy +Content-Type: text/rfc822-headers; protected-headers="v1" +Content-Disposition: inline + +From: XYZ +To: ProtonMail +Subject: Re: Encrypted Message + +--xnAIW3Turb9YQZ2rXc2ZGZH45WepHIZyy +Content-Type: multipart/alternative; + boundary="------------F9E5AA6D49692F51484075E3" +Content-Language: en-US + +This is a multi-part message in MIME format. +--------------F9E5AA6D49692F51484075E3 +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + +Hi ... + +--------------F9E5AA6D49692F51484075E3 +Content-Type: text/html; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + + + + + +

    Hi ..

    + + + +--------------F9E5AA6D49692F51484075E3-- + +--xnAIW3Turb9YQZ2rXc2ZGZH45WepHIZyy-- + +--ac7e36bd45425e70b4dab2128f34172e4dc3f9ff2eeb47e909267d4252794ec7-- + + +` + + body, content, err := minimalParse(testMessage) + if err == nil { + t.Fatal("should have error but is", err) + } + t.Log("==BODY==") + t.Log(body) + t.Log("==CONTENT==") + t.Log(content) +} + +func TestParse(t *testing.T) { + testMessage := + `From: John Doe +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="XXXXboundary text" + +This is a multipart message in MIME format. + +--XXXXboundary text +Content-Type: text/plain; charset=utf-8 + +this is the body text + +--XXXXboundary text +Content-Type: text/html; charset=utf-8 + +this is the html body text + +--XXXXboundary text +Content-Type: text/plain; charset=utf-8 +Content-Disposition: attachment; + filename="test.txt" + +this is the attachment text + +--XXXXboundary text-- + + +` + body, heads, att, attHeads, err := androidParse(testMessage) + if err != nil { + t.Error("parse error", err) + } + + fmt.Println("==BODY:") + fmt.Println(body) + fmt.Println("==BODY HEADERS:") + fmt.Println(heads) + + fmt.Println("==ATTACHMENTS:") + fmt.Println(att) + fmt.Println("==ATTACHMENT HEADERS:") + fmt.Println(attHeads) +} + +func TestParseAddressComment(t *testing.T) { + parsingExamples := map[string]string{ + "": "", + "(Only Comment) here@pm.me": "\"Only Comment\" ", + "Normal Name (With Comment) ": "\"Normal Name\" ", + "": "\"I am the greatest the\" ", + } + + for raw, expected := range parsingExamples { + parsed := parseAddressComment(raw) + if expected != parsed { + t.Errorf("When parsing %q expected %q but have %q", raw, expected, parsed) + } + } +} diff --git a/pkg/mime/utf7Decoder.go b/pkg/mime/utf7Decoder.go new file mode 100644 index 00000000..43c934bd --- /dev/null +++ b/pkg/mime/utf7Decoder.go @@ -0,0 +1,188 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmmime + +import ( + "encoding/base64" + "errors" + "unicode/utf16" + "unicode/utf8" + + "golang.org/x/text/encoding" + "golang.org/x/text/transform" +) + +// utf7Decoder copied from: https://github.com/cention-sany/utf7/blob/master/utf7.go +// We need `encoding.Decoder` instead of function `UTF7DecodeBytes`. +type utf7Decoder struct { + transform.NopResetter +} + +// NewUtf7Decoder returns a new decoder for utf7. +func NewUtf7Decoder() *encoding.Decoder { + return &encoding.Decoder{Transformer: utf7Decoder{}} +} + +const ( + uRepl = '\uFFFD' // Unicode replacement code point + u7min = 0x20 // Minimum self-representing UTF-7 value + u7max = 0x7E // Maximum self-representing UTF-7 value +) + +// ErrBadUTF7 is returned to indicate the invalid modified UTF-7 encoding. +var ErrBadUTF7 = errors.New("utf7: bad utf-7 encoding") + +const modifiedbase64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + +var u7enc = base64.NewEncoding(modifiedbase64) + +func isModifiedBase64(r byte) bool { + if r >= 'A' && r <= 'Z' { + return true + } else if r >= 'a' && r <= 'z' { + return true + } else if r >= '0' && r <= '9' { + return true + } else if r == '+' || r == '/' { + return true + } + return false +} + +func (d utf7Decoder) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err error) { + var implicit bool + var tmp int + + nd, n := len(dst), len(src) + if n == 0 && !atEOF { + return 0, 0, transform.ErrShortSrc + } + for ; nSrc < n; nSrc++ { + if nDst >= nd { + return nDst, nSrc, transform.ErrShortDst + } + if c := src[nSrc]; ((c < u7min || c > u7max) && + c != '\t' && c != '\r' && c != '\n') || + c == '~' || c == '\\' { + return nDst, nSrc, ErrBadUTF7 // Illegal code point in ASCII mode. + } else if c != '+' { + dst[nDst] = c // Character is self-representing. + nDst++ + continue + } + // Found '+'. + start := nSrc + 1 + tmp = nSrc // nSrc still points to '+', tmp points to the end of BASE64. + + // Find the end of the Base64 or "+-" segment. + implicit = false + for tmp++; tmp < n && src[tmp] != '-'; tmp++ { + if !isModifiedBase64(src[tmp]) { + if tmp == start { + return nDst, tmp, ErrBadUTF7 // '+' next char must modified base64. + } + // Implicit shift back to ASCII, no need for '-' character. + implicit = true + break + } + } + if tmp == start { + if tmp == n { + // Did not find '-' sign and '+' is the last character. + // Total nSrc does not include '+'. + if atEOF { + return nDst, nSrc, ErrBadUTF7 // '+' can not be at the end. + } + // '+' can not be at the end, the source is too short. + return nDst, nSrc, transform.ErrShortSrc + } + dst[nDst] = '+' // Escape sequence "+-". + nDst++ + } else if tmp == n && !atEOF { + // No EOF found, the source is too short. + return nDst, nSrc, transform.ErrShortSrc + } else if b := utf7dec(src[start:tmp]); len(b) > 0 { + if len(b)+nDst > nd { + // Need more space in dst for the decoded modified BASE64 unicode. + // Total nSrc does not include '+'. + return nDst, nSrc, transform.ErrShortDst + } + copy(dst[nDst:], b) // Control or non-ASCII code points in Base64. + nDst += len(b) + if implicit { + if nDst >= nd { + return nDst, tmp, transform.ErrShortDst + } + dst[nDst] = src[tmp] // Implicit shift. + nDst++ + } + if tmp == n { + return nDst, tmp, nil + } + } else { + return nDst, nSrc, ErrBadUTF7 // Bad encoding. + } + nSrc = tmp + } + return +} + +// utf7dec extracts UTF-16-BE bytes from Base64 data and converts them to UTF-8. +// A nil slice is returned if the encoding is invalid. +func utf7dec(b64 []byte) []byte { + var b []byte + + // Allocate a single block of memory large enough to store the Base64 data + // (if padding is required), UTF-16-BE bytes, and decoded UTF-8 bytes. + // Since a 2-byte UTF-16 sequence may expand into a 3-byte UTF-8 sequence, + // double the space allocation for UTF-8. + if n := len(b64); b64[n-1] == '=' { + return nil + } else if n&3 == 0 { + b = make([]byte, u7enc.DecodedLen(n)*3) + } else { + n += 4 - n&3 + b = make([]byte, n+u7enc.DecodedLen(n)*3) + copy(b[copy(b, b64):n], []byte("==")) + b64, b = b[:n], b[n:] + } + + // Decode Base64 into the first 1/3rd of b. + n, err := u7enc.Decode(b, b64) + if err != nil || n&1 == 1 { + return nil + } + + // Decode UTF-16-BE into the remaining 2/3rds of b. + b, s := b[:n], b[n:] + j := 0 + for i := 0; i < n; i += 2 { + r := rune(b[i])<<8 | rune(b[i+1]) + if utf16.IsSurrogate(r) { + if i += 2; i == n { + return nil + } + r2 := rune(b[i])<<8 | rune(b[i+1]) + if r = utf16.DecodeRune(r, r2); r == uRepl { + return nil + } + } + j += utf8.EncodeRune(s[j:], r) + } + return s[:j] +} diff --git a/pkg/parallel/parallel.go b/pkg/parallel/parallel.go new file mode 100644 index 00000000..8cd7769d --- /dev/null +++ b/pkg/parallel/parallel.go @@ -0,0 +1,136 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package parallel + +import ( + "sync" + "time" +) + +// parallelJob is to be used for passing items between input, worker and +// collector. `idx` is there to know the original order. +type parallelJob struct { + idx int + value interface{} +} + +// RunParallel starts `workers` number of workers and feeds them with `input` data. +// Each worker calls `process`. Processed data is collected in the same order as +// the input and is passed in order to the `collect` callback. If an error +// occurs, the execution is stopped and the error returned. +// runParallel blocks until everything is done. +func RunParallel( //nolint[funlen] + workers int, + input []interface{}, + process func(interface{}) (interface{}, error), + collect func(int, interface{}) error, +) (resultError error) { + wgProcess := &sync.WaitGroup{} + wgCollect := &sync.WaitGroup{} + + // Optimise by not executing the code at all if there is no input + // or run less workers than requested if there are few inputs. + inputLen := len(input) + if inputLen == 0 { + return nil + } + if inputLen < workers { + workers = inputLen + } + + inputChan := make(chan *parallelJob) + outputChan := make(chan *parallelJob) + + orderedCollectLock := &sync.Mutex{} + orderedCollect := make(map[int]interface{}) + + // Feed input channel used by workers with input data with index for ordering. + go func() { + defer close(inputChan) + for idx, item := range input { + if resultError != nil { + break + } + inputChan <- ¶llelJob{idx, item} + } + }() + + // Start workers and process all the inputs. + wgProcess.Add(workers) + for i := 0; i < workers; i++ { + go func() { + defer wgProcess.Done() + for item := range inputChan { + if output, err := process(item.value); err != nil { + resultError = err + break + } else { + outputChan <- ¶llelJob{item.idx, output} + } + } + }() + } + + // Collect data into map with the original position in the array. + wgCollect.Add(1) + go func() { + defer wgCollect.Done() + for output := range outputChan { + orderedCollectLock.Lock() + orderedCollect[output.idx] = output.value + orderedCollectLock.Unlock() + } + }() + + // Collect data in the same order as in the input array. + wgCollect.Add(1) + go func() { + defer wgCollect.Done() + idx := 0 + for { + if idx >= inputLen || resultError != nil { + break + } + orderedCollectLock.Lock() + value, ok := orderedCollect[idx] + if ok { + if err := collect(idx, value); err != nil { + resultError = err + } + delete(orderedCollect, idx) + idx++ + } + orderedCollectLock.Unlock() + if !ok { + time.Sleep(10 * time.Millisecond) + } + } + }() + + // When input channel is closed, all workers will finish. We need to wait + // for all of them and close the output channel only once. + wgProcess.Wait() + close(outputChan) + + // When workers are done, the last job is to finish collecting data. First + // collector is finished when output channel is closed and the second one + // when all items are passed to `collect` in the order or after an error. + wgCollect.Wait() + + return resultError +} diff --git a/pkg/parallel/parallel_test.go b/pkg/parallel/parallel_test.go new file mode 100644 index 00000000..8fc999de --- /dev/null +++ b/pkg/parallel/parallel_test.go @@ -0,0 +1,131 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package parallel + +import ( + "errors" + "fmt" + "math" + "testing" + "time" + + r "github.com/stretchr/testify/require" +) + +// nolint[gochecknoglobals] +var ( + testInput = []interface{}{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} + wantOutput = []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} + testProcessSleep = 100 // ms + runParallelTimeOverhead = 100 // ms +) + +func TestParallel(t *testing.T) { + workersTests := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} + + for _, workers := range workersTests { + workers := workers + t.Run(fmt.Sprintf("%d", workers), func(t *testing.T) { + collected := make([]int, 0) + collect := func(idx int, value interface{}) error { + collected = append(collected, value.(int)) + return nil + } + + tstart := time.Now() + err := RunParallel(workers, testInput, processSleep, collect) + duration := time.Since(tstart) + + r.Nil(t, err) + r.Equal(t, wantOutput, collected) // Check the order is always kept. + + wantMinDuration := int(math.Ceil(float64(len(testInput))/float64(workers))) * testProcessSleep + wantMaxDuration := wantMinDuration + runParallelTimeOverhead + r.True(t, duration.Nanoseconds() > int64(wantMinDuration*1000000), "Duration too short: %v (expected: %v)", duration, wantMinDuration) + r.True(t, duration.Nanoseconds() < int64(wantMaxDuration*1000000), "Duration too long: %v (expected: %v)", duration, wantMaxDuration) + }) + } +} + +func TestParallelEmptyInput(t *testing.T) { + workersTests := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} + + for _, workers := range workersTests { + workers := workers + t.Run(fmt.Sprintf("%d", workers), func(t *testing.T) { + err := RunParallel(workers, []interface{}{}, processSleep, collectNil) + r.Nil(t, err) + }) + } +} + +func TestParallelErrorInProcess(t *testing.T) { + workersTests := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} + + for _, workers := range workersTests { + workers := workers + t.Run(fmt.Sprintf("%d", workers), func(t *testing.T) { + var lastCollected int + process := func(value interface{}) (interface{}, error) { + time.Sleep(10 * time.Millisecond) + if value.(int) == 5 { + return nil, errors.New("Error") + } + return value, nil + } + collect := func(idx int, value interface{}) error { + lastCollected = value.(int) + return nil + } + + err := RunParallel(workers, testInput, process, collect) + r.EqualError(t, err, "Error") + + time.Sleep(10 * time.Millisecond) + r.True(t, lastCollected < 5, "Last collected cannot be higher that 5, got: %d", lastCollected) + }) + } +} + +func TestParallelErrorInCollect(t *testing.T) { + workersTests := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} + + for _, workers := range workersTests { + workers := workers + t.Run(fmt.Sprintf("%d", workers), func(t *testing.T) { + collect := func(idx int, value interface{}) error { + if value.(int) == 5 { + return errors.New("Error") + } + return nil + } + + err := RunParallel(workers, testInput, processSleep, collect) + r.EqualError(t, err, "Error") + }) + } +} + +func processSleep(value interface{}) (interface{}, error) { + time.Sleep(time.Duration(testProcessSleep) * time.Millisecond) + return value.(int), nil +} + +func collectNil(idx int, value interface{}) error { + return nil +} diff --git a/pkg/pmapi/Changelog.md b/pkg/pmapi/Changelog.md new file mode 100644 index 00000000..50d8edb4 --- /dev/null +++ b/pkg/pmapi/Changelog.md @@ -0,0 +1,309 @@ +# Do not modify this file! +It is here for historical reasons only. All changes should be documented in the +Changelog at the root of this repository. + + +# Changelog for API +> NOTE we are using versioning for go-pmapi in format `major.minor.bugfix` +> * major stays at version 1 for the forseeable future +> * minor is increased when a force upgrade happens or in case of major breaking changes +> * patch is increased when new features are added + +## v1.0.16 + +### Fixed +* Potential crash when reporting cert pin failure + +## v1.0.15 + +### Changed +* Merge only 50 events into one +* Response header timeout increased from 10s to 30s + +### Fixed +* Make keyring unlocking threadsafe + +## v1.0.14 + +### Added +* Config for disabling TLS cert fingerprint checking + +### Fixed +* Ensure sensitive stuff is cleared on client logout even if requests fail + +## v1.0.13 + +### Fixed +* Correctly set Transport in http client + +## v1.0.12 + +### Changed +* Only `http.RoundTripper` interface is needed instead of full `http.Transport` struct + +### Added +* GODT-61 (and related): Use DoH to find and switch to a proxy server if the API becomes unreachable +* GODT-67 added random wait to not cause spikes on server after StatusTooManyRequests + +### Fixed +* FirstReadTimeout was wrongly timeout of the whole request including repeating ones, now it's really only timeout for the first read + +## v1.0.11 + +### Added +* GODT-53 `Message.Type` added with constants `MessageType*` + +## v1.0.10 + +### Added +* GODT-55 exporting DANGEROUSLYSetUID + +### Changed +* The full communication between clien and API is logged if logrus level is trace + +## v1.0.9 + +### Fixed +* Use correct address type value (because API starts counting from 1 but we were counting from 0) + +## v1.0.8 + +### Added +* Introdcution of connection manager + +### Fixed +* Deadlock during the auth-refresh +* Fixed an issue where some events were being discarded when merging + +## v1.0.7 + +### Changed +* The given access token is saved during auth refresh if none was available yet + + +## v1.0.6 + +### Added +* `ClientConfig.Timeout` to be able to configure the whole timeout of request +* `ClientConfig.FirstReadTimeout` to be able to configure the timeout of request to the first byte +* `ClientConfig.MinSpeed` to be able to configure the timeout when the connection is too slow (limitation in minimum bytes per second) +* Set default timeouts for http.Transport with certificate pinning + +### Changed +* http.Client by default uses ProxyFromEnvironment to support HTTP_PROXY and HTTPS_PROXY environment variables + +## v1.0.5 + +### Added +* `ContentTypeMultipartEncrypted` MIME content type for encrypted email +* `MessageCounts` in event struct + +## v1.0.4 + +### Added +* `PMKeys` for parsing and reading KeyRing +* `clearableKey` to rewrite memory +* Proton/backend-communication#25 Unlock with tokens (OneKey2RuleThemAll Phase I) + +### Changed +* Update of gopenpgp: convert JSON to KeyRing in PMAPI +* `user.KeyRing` -> `user.KeyRing()` +* typo `client.GetAddresses()` + +### Removed +* `address.KeyRing` + +## v1.0.2 v1.0.3 + +### Changed +* Fixed capitalisation in a few places +* Added /metrics API route +* Changed function names to be compliant with go linter +* Encrypt with primary key only +* Fix `client.doBuffered` - closing body before handling unauthorized request +* go-pm-crypto -> GopenPGP +* redefine old functions in `keyring.go` +* `attachment.Decrypt` drops returning signature (does signature check by default) +* `attachment.Encrypt` is using readers instead of writers +* `attachment.DetachedSign` drops writer param and returns signature as a reader +* `message.Decrypt` drops returning signature (does signature check by default) +* Changed TLS report URL to https://reports.protonmail.ch/reports/tls +* Moved from current to soon TLS pin + +## v1.0.1 + +### Removed +* `ClientID` from all auth routes +* `ErrorDescription` from error + +## v1.0.0 + +### Changed +* `client.AuthInfo` does return 2FA information only when authenticated, for the first login information available in `Auth.HasTwoFactor` +* `client.Auth` does not accept 2FA code in favor of `client.Auth2FA` +* `client.Unlock` supports only new way of unlock with directly available access token + +### Added +* `Res.StatusCode` to pass HTTP status code to responses +* `Auth.HasTwoFactor` method to determine whether account has enabled 2FA (same as `AuthInfo.HasTwoFactor`) +* `Auth2FA*` structs for 2FA endpoint +* `client.Auth2FA` method to fully unlock session with 2FA code +* `ErrUnauthorized` when request cannot be authorized +* `ErrBad2FACode` when bad 2FA and user cannot try again +* `ErrBad2FACodeTryAgain` when bad 2FA but user can try again + +## 2019-08-06 + +### Added +* Send TLS issue report to API +* Cert fingerpring with `TLSPinning` struct +* Check API certificate fingerprint and verify hostname + +### Changed +* Using `AddressID` for `/messge/count` and `/conversations/count` +* Less of copying of responses from the server in the memory + +## 2019-08-01 +* low case for `sirupsen` +* using go modules + +## 2019-07-15 + +### Changed +* `client.Auths` field is removed in favor of function `client.SetAuths` which opens possibility to use interface + +## 2019-05-18 + +### Changed +* proton/backend-communication#11 x-pm-uid sent always for `/auth/refresh` +* proton/backend-communication#11 UID never changes + +## 2019-05-28 + +### Added +* New test server patern using callbacks +* Responses are read from json files + +### Changed +* `auth_tests.go` to new callback server pattern +* Linter fixes for tests + +### Removed +* `TestClient_Do_expired` due to no effect, use `DoUnauthorized` instead + +## 2019-05-24 +* Help functions for test +* CI with Lint + +## 2019-05-23 +* Log userID + +## 2019-05-21 +* Fix unlocking user keys + +## 2019-04-25 + +### Changed +* rename `Uid` -> `UID` proton/backend-communication#11 + +## 2019-04-09 + +### Added +* sending attachments as zip `application/octet-stream` +* function `ReportReq.AddAttachment()` +* data memeber `ReportReq.Attachments` +* general function to report bug `client.Report(req ReportReq)` with object as parameter + +### Changed +* `client.ReportBug` and `client.ReportBugWithClient` functions are obsolete and they uses `client.Report(req ReportReq)` +* `client.ReportCrash` is obsolete. Use sentry instead +* `Api`->`API`, `Uid`->`UID` + +## 2019-03-13 +* user id in raven +* add file position of panic sender + +## 2019-03-06 +* #30 update `pm-crypto` to store `KeyRing.FirstKeyID` +* #30 Add key salt to `Auth` object from `GetKeySalts` request +* #30 Add route `GET /keys/salt` +* removed unused `PmCrypto` + +## 2019-02-20 +* removed unused `decryptAccessToken` + +## 2019-01-21 +* #29 Parsing all goroutines from pprof +* #29 Sentry `Threads` implementation +* #29 using sentry for crashes + +## 2019-01-07 +* refactor `pmapi.DecryptString` -> `pmcrypto.KeyRing.DecryptString` +* fixed tests +* `crypto` -> `pmcrypto` +* refactoring code using repos `go-pm-crypto`, `go-pm-mime` and `go-srp` + + +## 2018-12-10 +* #26 adding `Flags` field to message +* #26 removing fields deprecated by `Flags`: `IsEncrypted`, `Type`, `IsReplied`, `IsRepliedAll`, `IsForwarded` +* #26 removing deprecated consts (see #26 for replacement) +* #26 fixing tests (compiling not working) + +## 2018-11-19 + +### Added +* Wait and retry from `DoJson` if banned from api + +### Changed +* `ErrNoInternet` -> `ErrAPINotReachable` +* Adding codes for force upgrade: 5004 and 5005 +* Adding codes for API offline: 7001 +* Adding codes for BansRequests: 85131 + +## 2018-09-18 + +### Added +* `client.decryptAccessToken` if privateKey is received (tested with local api) #23 + +### Changed +* added fields to User +* local config TLS skip verify + +## 2018-09-06 + +### Changed +* decrypt token only if needed + +### Broken +* Tests are not working + +## APIv3 UPDATE (2018-08-01) +* issue Desktop-Bridge#561 + +### Added +* Key flag consts +* `EventAddress` +* `MailSettings` object and route call +* `Client.KeyRingForAddressID` +* `AuthInfo.HasTwoFactor()` +* `Auth.HasMailboxPassword()` + +### Changed +* Addresses are part of client +* Update user updates also addresses +* `BodyKey` and `AttachmentKey` contains `Key` and `Algorithm` +* `keyPair` (not use Pubkey) -> `pmKeyObject` +* lots of indent +* bugs route +* two factor (ready to U2F) +* Reorder some to match order in doc (easier to ) +* omit address Order when empty +* update user and addresses in `CurrentUser()` +* `User.Unlock()` -> `Client.UnlockAddresses()` +* `AuthInfo.Uid` -> `AuthInfo.Uid()` +* `User.Addresses` -> `Client.Addresses()` + +### Removed +* User v3 removed plenty (now in settings) +* Message v3 removed plenty (Starred is label) diff --git a/pkg/pmapi/Makefile b/pkg/pmapi/Makefile new file mode 100644 index 00000000..d1742be0 --- /dev/null +++ b/pkg/pmapi/Makefile @@ -0,0 +1,19 @@ +export GO111MODULE=on + +LINTVER="v1.21.0" +LINTSRC="https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh" + +check-has-go: + @which go || (echo "Install Go-lang!" && exit 1) + +install-dev-dependencies: install-linter + +install-linter: check-has-go + curl -sfL $(LINTSRC) | sh -s -- -b $(shell go env GOPATH)/bin $(LINTVER) + +lint: + which golangci-lint || $(MAKE) install-linter + golangci-lint run ./... \ + +test: + go test -run=${TESTRUN} ./... diff --git a/pkg/pmapi/addresses.go b/pkg/pmapi/addresses.go new file mode 100644 index 00000000..dba5d9ee --- /dev/null +++ b/pkg/pmapi/addresses.go @@ -0,0 +1,204 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "fmt" + "strings" + + pmcrypto "github.com/ProtonMail/gopenpgp/crypto" +) + +// Address statuses. +const ( + DisabledAddress = iota + EnabledAddress +) + +// Address receive values. +const ( + CannotReceive = iota + CanReceive +) + +// Address HasKeys values. +const ( + MissingKeys = iota + KeysPresent +) + +// Address types. +const ( + _ = iota // Skip first. + OriginalAddress + AliasAddress + CustomAddress + PremiumAddress +) + +// Address Send values. +const ( + NoSendAddress = iota + MainSendAddress + SecondarySendAddress +) + +// Address represents a user's address. +type Address struct { + ID string + DomainID string + Email string + Send int + Receive int + Status int + Order int `json:",omitempty"` + Type int + DisplayName string + Signature string + MemberID string `json:",omitempty"` + MemberName string `json:",omitempty"` + + HasKeys int + Keys PMKeys +} + +// AddressList is a list of addresses. +type AddressList []*Address + +type AddressesRes struct { + Res + Addresses AddressList +} + +// KeyRing returns the (possibly unlocked) PMKeys KeyRing. +func (a *Address) KeyRing() *pmcrypto.KeyRing { + return a.Keys.KeyRing +} + +// ByID returns an address by id. Returns nil if no address is found. +func (l AddressList) ByID(id string) *Address { + for _, addr := range l { + if addr.ID == id { + return addr + } + } + return nil +} + +func (l AddressList) ActiveEmails() (addresses []string) { + for _, a := range l { + if a.Receive == CanReceive { + addresses = append(addresses, a.Email) + } + } + return +} + +// Main gets the main address. +func (l AddressList) Main() *Address { + for _, addr := range l { + if addr.Order == 1 { + return addr + } + } + return l[0] // Should not happen. +} + +// ByEmail gets an address by email. Returns nil if no address is found. +func (l AddressList) ByEmail(email string) *Address { + email = SanitizeEmail(email) + for _, addr := range l { + if strings.EqualFold(addr.Email, email) { + return addr + } + } + return nil +} + +func SanitizeEmail(email string) string { + splitAt := strings.Split(email, "@") + if len(splitAt) != 2 { + return email + } + splitPlus := strings.Split(splitAt[0], "+") + email = splitPlus[0] + "@" + splitAt[1] + return email +} + +func ConstructAddress(headerEmail string, addressEmail string) string { + splitAtHeader := strings.Split(headerEmail, "@") + if len(splitAtHeader) != 2 { + return addressEmail + } + + splitPlus := strings.Split(splitAtHeader[0], "+") + if len(splitPlus) != 2 { + return addressEmail + } + + splitAtAddress := strings.Split(addressEmail, "@") + if len(splitAtAddress) != 2 { + return addressEmail + } + + return splitAtAddress[0] + "+" + splitPlus[1] + "@" + splitAtAddress[1] +} + +// GetAddresses requests all of current user addresses (without pagination). +func (c *Client) GetAddresses() (addresses AddressList, err error) { + req, err := NewRequest("GET", "/addresses", nil) + if err != nil { + return + } + + var res AddressesRes + if err = c.DoJSON(req, &res); err != nil { + return + } + + return res.Addresses, res.Err() +} + +func (c *Client) Addresses() AddressList { + return c.addresses +} + +// UnlockAddresses unlocks all keys for all addresses of current user. +func (c *Client) UnlockAddresses(passphrase []byte) (err error) { + for _, a := range c.addresses { + if a.HasKeys == MissingKeys { + continue + } + + // Unlock the address token using the UserKey, use the unlocked token to unlock the keyring. + if err = a.Keys.unlockKeyRing(c.kr, passphrase, c.keyLocker); err != nil { + err = fmt.Errorf("pmapi: cannot unlock private key of address %v: %v", a.Email, err) + return + } + } + + return +} + +func (c *Client) KeyRingForAddressID(addrID string) *pmcrypto.KeyRing { + addr := c.addresses.ByID(addrID) + if addr == nil { + addr = c.addresses.Main() + } + return addr.KeyRing() +} diff --git a/pkg/pmapi/addresses_test.go b/pkg/pmapi/addresses_test.go new file mode 100644 index 00000000..41863e3d --- /dev/null +++ b/pkg/pmapi/addresses_test.go @@ -0,0 +1,90 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "net/http" + "testing" +) + +var testAddressList = AddressList{ + &Address{ + ID: "1", + Email: "root@nsa.gov", + Send: SecondarySendAddress, + Status: EnabledAddress, + Order: 2, + }, + &Address{ + ID: "2", + Email: "root@gchq.gov.uk", + Send: MainSendAddress, + Status: EnabledAddress, + Order: 1, + }, + &Address{ + ID: "3", + Email: "root@protonmail.com", + Send: NoSendAddress, + Status: DisabledAddress, + Order: 3, + }, +} + +func routeGetAddresses(tb testing.TB, w http.ResponseWriter, r *http.Request) string { + Ok(tb, checkMethodAndPath(r, "GET", "/addresses")) + Ok(tb, isAuthReq(r, testUID, testAccessToken)) + return "addresses/get_response.json" +} + +func routeGetSalts(tb testing.TB, w http.ResponseWriter, r *http.Request) string { + Ok(tb, checkMethodAndPath(r, "GET", "/keys/salts")) + Ok(tb, isAuthReq(r, testUID, testAccessToken)) + return "keys/salts/get_response.json" +} + +func TestAddressList(t *testing.T) { + input := "1" + addr := testAddressList.ByID(input) + if addr != testAddressList[0] { + t.Errorf("ById(%s) expected:\n%v\n but have:\n%v\n", input, testAddressList[0], addr) + } + + input = "42" + addr = testAddressList.ByID(input) + if addr != nil { + t.Errorf("ById expected nil for %s but have : %v\n", input, addr) + } + + input = "root@protonmail.com" + addr = testAddressList.ByEmail(input) + if addr != testAddressList[2] { + t.Errorf("ByEmail(%s) expected:\n%v\n but have:\n%v\n", input, testAddressList[2], addr) + } + + input = "idontexist@protonmail.com" + addr = testAddressList.ByEmail(input) + if addr != nil { + t.Errorf("ByEmail expected nil for %s but have : %v\n", input, addr) + } + + addr = testAddressList.Main() + if addr != testAddressList[1] { + t.Errorf("Main() expected:\n%v\n but have:\n%v\n", testAddressList[1], addr) + } +} diff --git a/pkg/pmapi/attachments.go b/pkg/pmapi/attachments.go new file mode 100644 index 00000000..b63be384 --- /dev/null +++ b/pkg/pmapi/attachments.go @@ -0,0 +1,264 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/textproto" + + pmcrypto "github.com/ProtonMail/gopenpgp/crypto" +) + +type header textproto.MIMEHeader + +type rawHeader map[string]json.RawMessage + +func (h *header) UnmarshalJSON(b []byte) error { + if *h == nil { + *h = make(header) + } + + raw := make(rawHeader) + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + + for k, v := range raw { + // Most headers are string because they have only one value. + var s string + if err := json.Unmarshal(v, &s); err == nil { + textproto.MIMEHeader(*h).Set(k, s) + continue + } + + // If it's not a string, it must be an array of strings. + var a []string + if err := json.Unmarshal(v, &a); err != nil { + return fmt.Errorf("pmapi: attachment header field is neither a string nor an array of strings: %v", err) + } + for _, vv := range a { + textproto.MIMEHeader(*h).Add(k, vv) + } + } + + return nil +} + +// Attachment represents a message attachment. +type Attachment struct { + ID string `json:",omitempty"` + MessageID string `json:",omitempty"` // msg v3 ??? + Name string `json:",omitempty"` + Size int64 `json:",omitempty"` + MIMEType string `json:",omitempty"` + ContentID string `json:",omitempty"` + KeyPackets string `json:",omitempty"` + Signature string `json:",omitempty"` + + Header textproto.MIMEHeader `json:"-"` +} + +// Define a new type to prevent MarshalJSON/UnmarshalJSON infinite loops. +type attachment Attachment + +type rawAttachment struct { + attachment + + Header header `json:"Headers,omitempty"` +} + +func (a *Attachment) MarshalJSON() ([]byte, error) { + var raw rawAttachment + raw.attachment = attachment(*a) + + if a.Header != nil { + raw.Header = header(a.Header) + } + + return json.Marshal(&raw) +} + +func (a *Attachment) UnmarshalJSON(b []byte) error { + var raw rawAttachment + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + + *a = Attachment(raw.attachment) + + if raw.Header != nil { + a.Header = textproto.MIMEHeader(raw.Header) + } + + return nil +} + +// Decrypt decrypts this attachment's data from r using the keys from kr. +func (a *Attachment) Decrypt(r io.Reader, kr *pmcrypto.KeyRing) (decrypted io.Reader, err error) { + keyPackets, err := base64.StdEncoding.DecodeString(a.KeyPackets) + if err != nil { + return + } + return decryptAttachment(kr, keyPackets, r) +} + +// Encrypt encrypts an attachment. +func (a *Attachment) Encrypt(kr *pmcrypto.KeyRing, att io.Reader) (encrypted io.Reader, err error) { + return encryptAttachment(kr, att, a.Name) +} + +func (a *Attachment) DetachedSign(kr *pmcrypto.KeyRing, att io.Reader) (signed io.Reader, err error) { + return signAttachment(kr, att) +} + +type CreateAttachmentRes struct { + Res + + Attachment *Attachment +} + +func writeAttachment(w *multipart.Writer, att *Attachment, r io.Reader, sig io.Reader) (err error) { + // Create metadata fields. + if err = w.WriteField("Filename", att.Name); err != nil { + return + } + if err = w.WriteField("MessageID", att.MessageID); err != nil { + return + } + if err = w.WriteField("MIMEType", att.MIMEType); err != nil { + return + } + + if err = w.WriteField("ContentID", att.ContentID); err != nil { + return + } + + // And send attachment data. + ff, err := w.CreateFormFile("DataPacket", "DataPacket.pgp") + if err != nil { + return + } + if _, err = io.Copy(ff, r); err != nil { + return + } + + // And send attachment data. + sigff, err := w.CreateFormFile("Signature", "Signature.pgp") + if err != nil { + return + } + + if _, err = io.Copy(sigff, sig); err != nil { + return + } + + return err +} + +// CreateAttachment uploads an attachment. It must be already encrypted and contain a MessageID. +// +// The returned created attachment contains the new attachment ID and its size. +func (c *Client) CreateAttachment(att *Attachment, r io.Reader, sig io.Reader) (created *Attachment, err error) { + req, w, err := NewMultipartRequest("POST", "/attachments") + if err != nil { + return + } + + // We will write the request as long as it is sent to the API. + var res CreateAttachmentRes + done := make(chan error, 1) + go (func() { + done <- c.DoJSON(req, &res) + })() + + if err = writeAttachment(w.Writer, att, r, sig); err != nil { + return + } + _ = w.Close() + + if err = <-done; err != nil { + return + } + if err = res.Err(); err != nil { + return + } + + created = res.Attachment + return +} + +type UpdateAttachmentSignatureReq struct { + Signature string +} + +func (c *Client) UpdateAttachmentSignature(attachmentID, signature string) (err error) { + updateReq := &UpdateAttachmentSignatureReq{signature} + req, err := NewJSONRequest("PUT", "/attachments/"+attachmentID+"/signature", updateReq) + if err != nil { + return + } + + var res Res + if err = c.DoJSON(req, &res); err != nil { + return + } + + return +} + +// DeleteAttachment removes an attachment. message is the message ID, att is the attachment ID. +func (c *Client) DeleteAttachment(attID string) (err error) { + req, err := NewRequest("DELETE", "/attachments/"+attID, nil) + if err != nil { + return + } + + var res Res + if err = c.DoJSON(req, &res); err != nil { + return + } + + err = res.Err() + return +} + +// GetAttachment gets an attachment's content. The returned data is encrypted. +func (c *Client) GetAttachment(id string) (att io.ReadCloser, err error) { + if id == "" { + err = errors.New("pmapi: cannot get an attachment with an empty id") + return + } + + req, err := NewRequest("GET", "/attachments/"+id, nil) + if err != nil { + return + } + + res, err := c.Do(req, true) + if err != nil { + return + } + + att = res.Body + return +} diff --git a/pkg/pmapi/attachments_test.go b/pkg/pmapi/attachments_test.go new file mode 100644 index 00000000..204b6bdb --- /dev/null +++ b/pkg/pmapi/attachments_test.go @@ -0,0 +1,222 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "mime" + "mime/multipart" + "net/http" + "net/textproto" + "reflect" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +var testAttachment = &Attachment{ + ID: "y6uKIlc2HdoHPAwPSrvf7dXoZNMYvBgxshYUN67cY5DJjL2O8NYewuvGHcYvCfd8LpEoAI_GdymO0Jr0mHlsEw==", + Name: "croutonmail.txt", + Size: 77, + MIMEType: "text/plain", + KeyPackets: "wcBMA0fcZ7XLgmf2AQgAiRsOlnm1kSB4/lr7tYe6pBsRGn10GqwUhrwU5PMKOHdCgnO12jO3y3CzP0Yl/jGhAYja9wLDqH8X0sk3tY32u4Sb1Qe5IuzggAiCa4dwOJj5gEFMTHMzjIMPHR7A70XqUxMhmILye8V4KRm/j4c1sxbzA1rM3lYBumQuB5l/ck0Kgt4ZqxHVXHK5Q1l65FHhSXRj8qnunasHa30TYNzP8nmBA8BinnJxpiQ7FGc2umnUhgkFtjm5ixu9vyjr9ukwDTbwAXXfmY+o7tK7kqIXJcmTL6k2UeC6Mz1AagQtRCRtU+bv/3zGojq/trZo9lom3naIeQYa36Ketmcpj2Qwjg==", + Header: textproto.MIMEHeader{ + "Content-Description": {"You'll never believe what's in this text file"}, + "X-Mailer": {"Microsoft Outlook 15.0", "Microsoft Live Mail 42.0"}, + }, + MessageID: "h3CD-DT7rLoAw1vmpcajvIPAl-wwDfXR2MHtWID3wuQURDBKTiGUAwd6E2WBbS44QQKeXImW-axm6X0hAfcVCA==", +} + +const testAttachmentJSON = `{ + "ID": "y6uKIlc2HdoHPAwPSrvf7dXoZNMYvBgxshYUN67cY5DJjL2O8NYewuvGHcYvCfd8LpEoAI_GdymO0Jr0mHlsEw==", + "Name": "croutonmail.txt", + "Size": 77, + "MIMEType": "text/plain", + "KeyPackets": "wcBMA0fcZ7XLgmf2AQgAiRsOlnm1kSB4/lr7tYe6pBsRGn10GqwUhrwU5PMKOHdCgnO12jO3y3CzP0Yl/jGhAYja9wLDqH8X0sk3tY32u4Sb1Qe5IuzggAiCa4dwOJj5gEFMTHMzjIMPHR7A70XqUxMhmILye8V4KRm/j4c1sxbzA1rM3lYBumQuB5l/ck0Kgt4ZqxHVXHK5Q1l65FHhSXRj8qnunasHa30TYNzP8nmBA8BinnJxpiQ7FGc2umnUhgkFtjm5ixu9vyjr9ukwDTbwAXXfmY+o7tK7kqIXJcmTL6k2UeC6Mz1AagQtRCRtU+bv/3zGojq/trZo9lom3naIeQYa36Ketmcpj2Qwjg==", + "Headers": { + "content-description": "You'll never believe what's in this text file", + "x-mailer": [ + "Microsoft Outlook 15.0", + "Microsoft Live Mail 42.0" + ] + } +} +` + +const testAttachmentCleartext = `cc, +dille. +` + +const testAttachmentEncrypted = `wcBMA0fcZ7XLgmf2AQf/cHhfDRM9zlIuBi+h2W6DKjbbyIHMkgF6ER3JEvn/tSruUH8KTGt0N7Z+a80FFMCuXn1Y1I/nW7MVrNhGuJZAF4OymD8ugvuoAMIQX0eCYEpPXzRIWJBZg82AuowmFMsv8Dgvq4bTZq4cttI3CZcxKUNXuAearmNpmgplUKWj5USmRXK4iGB3VFGjidXkxbElrP4fD5A/rfEZ5aJgCsegqcXxX3MEjWXi9pFzgd/9phOvl1ZFm9U9hNoVAW3QsgmVeihnKaDZUyf2Qsigij21QKAUxw9U3y89eTUIqZAcmIgqeDujA3RWBgJwjtY/lOyhEmkf3AWKzehvf1xtJmCWDtJLAekuH+JfAtTQfMs5nf4zYtMahGbMkwy3Uz/jeEMYdzWY5WvshkbwvaxpqFC+11cqMLBvxik39i1xf+RORZF/91jGMCL9Z9dRMcgB` + +const testCreateAttachmentBody = `{ + "Code": 1000, + "Attachment": {"ID": "y6uKIlc2HdoHPAwPSrvf7dXoZNMYvBgxshYUN67cY5DJjL2O8NYewuvGHcYvCfd8LpEoAI_GdymO0Jr0mHlsEw=="} +}` + +const testDeleteAttachmentBody = `{ + "Code": 1000 +}` + +func TestAttachment_UnmarshalJSON(t *testing.T) { + att := new(Attachment) + if err := json.Unmarshal([]byte(testAttachmentJSON), att); err != nil { + t.Fatal("Expected no error while unmarshaling JSON, got:", err) + } + + att.MessageID = testAttachment.MessageID // This isn't in the JSON object + + if !reflect.DeepEqual(testAttachment, att) { + t.Errorf("Invalid attachment: expected %+v but got %+v", testAttachment, att) + } +} + +func TestClient_CreateAttachment(t *testing.T) { + s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Ok(t, checkMethodAndPath(r, "POST", "/attachments")) + + contentType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + t.Error("Expected no error while parsing request content type, got:", err) + } + if contentType != "multipart/form-data" { + t.Errorf("Invalid request content type: expected %v but got %v", "multipart/form-data", contentType) + } + + mr := multipart.NewReader(r.Body, params["boundary"]) + form, err := mr.ReadForm(10 * 1024) + if err != nil { + t.Error("Expected no error while parsing request form, got:", err) + } + defer Ok(t, form.RemoveAll()) + + if form.Value["Filename"][0] != testAttachment.Name { + t.Errorf("Invalid attachment filename: expected %v but got %v", testAttachment.Name, form.Value["Filename"][0]) + } + if form.Value["MessageID"][0] != testAttachment.MessageID { + t.Errorf("Invalid attachment message id: expected %v but got %v", testAttachment.MessageID, form.Value["MessageID"][0]) + } + if form.Value["MIMEType"][0] != testAttachment.MIMEType { + t.Errorf("Invalid attachment message id: expected %v but got %v", testAttachment.MIMEType, form.Value["MIMEType"][0]) + } + + dataFile, err := form.File["DataPacket"][0].Open() + if err != nil { + t.Error("Expected no error while opening packets file, got:", err) + } + defer Ok(t, dataFile.Close()) + + b, err := ioutil.ReadAll(dataFile) + if err != nil { + t.Error("Expected no error while reading packets file, got:", err) + } + if string(b) != testAttachmentCleartext { + t.Errorf("Invalid attachment packets: expected %v but got %v", testAttachment.KeyPackets, string(b)) + } + + fmt.Fprint(w, testCreateAttachmentBody) + })) + defer s.Close() + + r := strings.NewReader(testAttachmentCleartext) // In reality, this thing is encrypted + created, err := c.CreateAttachment(testAttachment, r, strings.NewReader("")) + if err != nil { + t.Fatal("Expected no error while creating attachment, got:", err) + } + + if created.ID != testAttachment.ID { + t.Errorf("Invalid attachment id: expected %v but got %v", testAttachment.ID, created.ID) + } +} + +func TestClient_DeleteAttachment(t *testing.T) { + s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Ok(t, checkMethodAndPath(r, "DELETE", "/attachments/"+testAttachment.ID)) + + b := &bytes.Buffer{} + if n, _ := b.ReadFrom(r.Body); n != 0 { + t.Fatal("expected no body but have: ", b.String()) + } + + fmt.Fprint(w, testDeleteAttachmentBody) + })) + defer s.Close() + + err := c.DeleteAttachment(testAttachment.ID) + if err != nil { + t.Fatal("Expected no error while deleting attachment, got:", err) + } +} + +func TestClient_GetAttachment(t *testing.T) { + s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Ok(t, checkMethodAndPath(r, "GET", "/attachments/"+testAttachment.ID)) + + fmt.Fprint(w, testAttachmentCleartext) + })) + defer s.Close() + + r, err := c.GetAttachment(testAttachment.ID) + if err != nil { + t.Fatal("Expected no error while getting attachment, got:", err) + } + defer r.Close() //nolint[errcheck] + + // In reality, r contains encrypted data + b, err := ioutil.ReadAll(r) + if err != nil { + t.Fatal("Expected no error while reading attachment, got:", err) + } + + if string(b) != testAttachmentCleartext { + t.Errorf("Invalid attachment data: expected %q but got %q", testAttachmentCleartext, string(b)) + } +} + +func TestAttachment_Encrypt(t *testing.T) { + data := bytes.NewBufferString(testAttachmentCleartext) + r, err := testAttachment.Encrypt(testPublicKeyRing, data) + assert.Nil(t, err) + b, err := ioutil.ReadAll(r) + assert.Nil(t, err) + + // Result is always different, so the best way is to test it by decrypting again. + // Another test for decrypting will help us to be sure it's working. + dataEnc := bytes.NewBuffer(b) + decryptAndCheck(t, dataEnc) +} + +func TestAttachment_Decrypt(t *testing.T) { + dataBytes, _ := base64.StdEncoding.DecodeString(testAttachmentEncrypted) + dataReader := bytes.NewBuffer(dataBytes) + decryptAndCheck(t, dataReader) +} + +func decryptAndCheck(t *testing.T, data io.Reader) { + r, err := testAttachment.Decrypt(data, testPrivateKeyRing) + assert.Nil(t, err) + b, err := ioutil.ReadAll(r) + assert.Nil(t, err) + assert.Equal(t, testAttachmentCleartext, string(b)) +} diff --git a/pkg/pmapi/auth.go b/pkg/pmapi/auth.go new file mode 100644 index 00000000..f00fc1ae --- /dev/null +++ b/pkg/pmapi/auth.go @@ -0,0 +1,506 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "crypto/subtle" + "encoding/base64" + "errors" + "net/http" + "strings" + "time" + + pmcrypto "github.com/ProtonMail/gopenpgp/crypto" + "github.com/ProtonMail/proton-bridge/pkg/srp" +) + +var ErrBad2FACode = errors.New("incorrect 2FA code") +var ErrBad2FACodeTryAgain = errors.New("incorrect 2FA code: please try again") + +type AuthInfoReq struct { + Username string +} + +type U2FInfo struct { + Challenge string + RegisteredKeys []struct { + Version string + KeyHandle string + } +} + +type TwoFactorInfo struct { + Enabled int // 0 for disabled, 1 for OTP, 2 for U2F, 3 for both. + TOTP int + U2F U2FInfo +} + +func (twoFactor *TwoFactorInfo) hasTwoFactor() bool { + return twoFactor.Enabled > 0 +} + +// AuthInfo contains data used when authenticating a user. It should be +// provided to Client.Auth(). Each AuthInfo can be used for only one login attempt. +type AuthInfo struct { + TwoFA *TwoFactorInfo `json:"2FA,omitempty"` + + version int + salt string + modulus string + srpSession string + serverEphemeral string +} + +func (a *AuthInfo) HasTwoFactor() bool { + if a.TwoFA == nil { + return false + } + return a.TwoFA.hasTwoFactor() +} + +type AuthInfoRes struct { + Res + AuthInfo + + Modulus string + ServerEphemeral string + Version int + Salt string + SRPSession string +} + +func (res *AuthInfoRes) getAuthInfo() *AuthInfo { + info := &res.AuthInfo + + // Some fields in AuthInfo are private, so we need to copy them from AuthRes + // (private fields cannot be populated by json). + info.version = res.Version + info.salt = res.Salt + info.modulus = res.Modulus + info.srpSession = res.SRPSession + info.serverEphemeral = res.ServerEphemeral + + return info +} + +type AuthReq struct { + Username string + ClientProof string + ClientEphemeral string + SRPSession string +} + +// Auth contains data after a successful authentication. It should be provided to Client.Unlock(). +type Auth struct { + accessToken string // Read from AuthRes. + ExpiresIn int + Scope string + uid string // Read from AuthRes. + RefreshToken string + KeySalt string + EventID string + PasswordMode int + TwoFA *TwoFactorInfo `json:"2FA,omitempty"` +} + +func (s *Auth) UID() string { + return s.uid +} + +func (s *Auth) HasTwoFactor() bool { + if s.TwoFA == nil { + return false + } + return s.TwoFA.hasTwoFactor() +} + +func (s *Auth) HasMailboxPassword() bool { + return s.PasswordMode == 2 +} + +func (s *Auth) hasFullScope() bool { + return strings.Contains(s.Scope, "full") +} + +type AuthRes struct { + Res + Auth + + AccessToken string + TokenType string + UID string + + ServerProof string +} + +func (res *AuthRes) getAuth() *Auth { + auth := &res.Auth + + // Some fields in Auth are private, so we need to copy them from AuthRes + // (private fields cannot be populated by json). + auth.accessToken = res.AccessToken + auth.uid = res.UID + + return auth +} + +type Auth2FAReq struct { + TwoFactorCode string + + // Prepared for U2F: + // U2F U2FRequest +} + +type Auth2FA struct { + Scope string +} + +type Auth2FARes struct { + Res + + Scope string +} + +func (res *Auth2FARes) getAuth2FA() *Auth2FA { + return &Auth2FA{ + Scope: res.Scope, + } +} + +type AuthRefreshReq struct { + ResponseType string + GrantType string + RefreshToken string + UID string + RedirectURI string + State string +} + +// SetAuths sets auths channel. +func (c *Client) SetAuths(auths chan<- *Auth) { + c.auths = auths +} + +// AuthInfo gets authentication info for a user. +func (c *Client) AuthInfo(username string) (info *AuthInfo, err error) { + infoReq := &AuthInfoReq{ + Username: username, + } + + req, err := NewJSONRequest("POST", "/auth/info", infoReq) + if err != nil { + return + } + + var infoRes AuthInfoRes + if err = c.DoJSON(req, &infoRes); err != nil { + return + } + + info, err = infoRes.getAuthInfo(), infoRes.Err() + + return +} + +func srpProofsFromInfo(info *AuthInfo, username, password string, fallbackVersion int) (proofs *srp.SrpProofs, err error) { + version := info.version + if version == 0 { + version = fallbackVersion + } + + srpAuth, err := srp.NewSrpAuth(version, username, password, info.salt, info.modulus, info.serverEphemeral) + if err != nil { + return + } + + proofs, err = srpAuth.GenerateSrpProofs(2048) + return +} + +func (c *Client) tryAuth(username, password string, info *AuthInfo, fallbackVersion int) (res *AuthRes, err error) { + proofs, err := srpProofsFromInfo(info, username, password, fallbackVersion) + if err != nil { + return + } + + authReq := &AuthReq{ + Username: username, + ClientEphemeral: base64.StdEncoding.EncodeToString(proofs.ClientEphemeral), + ClientProof: base64.StdEncoding.EncodeToString(proofs.ClientProof), + SRPSession: info.srpSession, + } + + req, err := NewJSONRequest("POST", "/auth", authReq) + if err != nil { + return + } + + var authRes AuthRes + if err = c.DoJSON(req, &authRes); err != nil { + return + } + + if err = authRes.Err(); err != nil { + return + } + + serverProof, err := base64.StdEncoding.DecodeString(authRes.ServerProof) + if err != nil { + return + } + + if subtle.ConstantTimeCompare(proofs.ExpectedServerProof, serverProof) != 1 { + return nil, errors.New("pmapi: bad server proof") + } + + res, err = &authRes, authRes.Err() + return res, err +} + +func (c *Client) tryFullAuth(username, password string, fallbackVersion int) (info *AuthInfo, authRes *AuthRes, err error) { + info, err = c.AuthInfo(username) + if err != nil { + return + } + authRes, err = c.tryAuth(username, password, info, fallbackVersion) + return +} + +// Auth will authenticate a user. +func (c *Client) Auth(username, password string, info *AuthInfo) (auth *Auth, err error) { + if info == nil { + if info, err = c.AuthInfo(username); err != nil { + return + } + } + + authRes, err := c.tryAuth(username, password, info, 2) + if err != nil && info.version == 0 && srp.CleanUserName(username) != strings.ToLower(username) { + info, authRes, err = c.tryFullAuth(username, password, 1) + } + if err != nil && info.version == 0 { + _, authRes, err = c.tryFullAuth(username, password, 0) + } + if err != nil { + return + } + + auth = authRes.getAuth() + c.uid = auth.UID() + c.accessToken = auth.accessToken + + if c.auths != nil { + c.auths <- auth + } + + if c.tokenManager != nil { + c.tokenManager.SetToken(c.userID, c.uid+":"+auth.RefreshToken) + c.log.Info("Set token from auth " + c.uid + ":" + auth.RefreshToken) + } + + // Auth has to be fully unlocked to get key salt. During `Auth` it can happen + // only to accounts without 2FA. For 2FA accounts, it's done in `Auth2FA`. + if auth.hasFullScope() { + err = c.setKeySaltToAuth(auth) + if err != nil { + return nil, err + } + } + + c.expiresAt = time.Now().Add(time.Duration(auth.ExpiresIn) * time.Second) + return auth, err +} + +// Auth2FA will authenticate a user into full scope. +// `Auth` struct contains method `HasTwoFactor` deciding whether this has to be done. +func (c *Client) Auth2FA(twoFactorCode string, auth *Auth) (*Auth2FA, error) { + auth2FAReq := &Auth2FAReq{ + TwoFactorCode: twoFactorCode, + } + + req, err := NewJSONRequest("POST", "/auth/2fa", auth2FAReq) + if err != nil { + return nil, err + } + + var auth2FARes Auth2FARes + if err := c.DoJSON(req, &auth2FARes); err != nil { + return nil, err + } + + if err := auth2FARes.Err(); err != nil { + switch auth2FARes.StatusCode { + case http.StatusUnauthorized: + return nil, ErrBad2FACode + case http.StatusUnprocessableEntity: + return nil, ErrBad2FACodeTryAgain + default: + return nil, err + } + } + + if err := c.setKeySaltToAuth(auth); err != nil { + return nil, err + } + + return auth2FARes.getAuth2FA(), nil +} + +func (c *Client) setKeySaltToAuth(auth *Auth) error { + // KeySalt already set up, no need to do it again. + if auth.KeySalt != "" { + return nil + } + + user, err := c.CurrentUser() + if err != nil { + return err + } + salts, err := c.GetKeySalts() + if err != nil { + return err + } + for _, s := range salts { + if s.ID == user.KeyRing().FirstKeyID { + auth.KeySalt = s.KeySalt + break + } + } + return nil +} + +// Unlock decrypts the key ring. +// If the password is invalid, IsUnlockError(err) will return true. +func (c *Client) Unlock(password string) (kr *pmcrypto.KeyRing, err error) { + if _, err = c.CurrentUser(); err != nil { + return + } + + c.keyLocker.Lock() + defer c.keyLocker.Unlock() + + kr = c.user.KeyRing() + if err = unlockKeyRingNoErrorWhenAlreadyUnlocked(kr, []byte(password)); err != nil { + return + } + + c.kr = kr + return kr, err +} + +// AuthRefresh will refresh an expired access token. +func (c *Client) AuthRefresh(uidAndRefreshToken string) (auth *Auth, err error) { + // If we don't yet have a saved access token, save this one in case the refresh fails! + // That way we can try again later (see handleUnauthorizedStatus). + if c.tokenManager != nil { + currentAccessToken := c.tokenManager.GetToken(c.userID) + if currentAccessToken == "" { + c.log.WithField("token", uidAndRefreshToken). + Info("Currently have no access token, setting given one") + c.tokenManager.SetToken(c.userID, uidAndRefreshToken) + } + } + + split := strings.Split(uidAndRefreshToken, ":") + if len(split) != 2 { + err = ErrInvalidToken + return + } + + refreshReq := &AuthRefreshReq{ + ResponseType: "token", + GrantType: "refresh_token", + RefreshToken: split[1], + UID: split[0], + RedirectURI: "https://protonmail.ch", + State: "random_string", + } + + // UID must be set for `x-pm-uid` header field, see backend-communication#11 + c.uid = split[0] + + req, err := NewJSONRequest("POST", "/auth/refresh", refreshReq) + if err != nil { + return + } + + var res AuthRes + if err = c.DoJSON(req, &res); err != nil { + return + } + if err = res.Err(); err != nil { + return + } + + auth = res.getAuth() + // UID should never change after auth, see backend-communication#11 + auth.uid = c.uid + if c.auths != nil { + c.auths <- auth + } + + c.uid = auth.UID() + c.accessToken = auth.accessToken + + if c.tokenManager != nil { + c.tokenManager.SetToken(c.userID, c.uid+":"+res.RefreshToken) + c.log.Info("Set token from auth refresh " + c.uid + ":" + res.RefreshToken) + } + + c.expiresAt = time.Now().Add(time.Duration(auth.ExpiresIn) * time.Second) + return auth, err +} + +// Logout logs the current user out. +func (c *Client) Logout() (err error) { + req, err := NewRequest("DELETE", "/auth", nil) + if err != nil { + return + } + + var res Res + if err = c.DoJSON(req, &res); err != nil { + return + } + + if err = res.Err(); err != nil { + return + } + + // This can trigger a deadlock! We don't want to do it if the above requests failed (GODT-154). + // That's why it's not in the deferred statement above. + if c.auths != nil { + c.auths <- nil + } + + // This should ideally be deferred at the top of this method so that it is executed + // regardless of what happens, but we currently don't have a way to prevent ourselves + // from using a logged out client. So for now, it's down here, as it was in Charles release. + // defer func() { + c.uid = "" + c.accessToken = "" + c.kr = nil + // c.addresses = nil + c.user = nil + if c.tokenManager != nil { + c.tokenManager.SetToken(c.userID, "") + } + // }() + + return err +} diff --git a/pkg/pmapi/auth_test.go b/pkg/pmapi/auth_test.go new file mode 100644 index 00000000..7cbea94b --- /dev/null +++ b/pkg/pmapi/auth_test.go @@ -0,0 +1,366 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "encoding/json" + "math/rand" + "net/http" + "testing" + "time" + + pmcrypto "github.com/ProtonMail/gopenpgp/crypto" + "github.com/ProtonMail/proton-bridge/pkg/srp" + + "github.com/sirupsen/logrus" + + a "github.com/stretchr/testify/assert" + r "github.com/stretchr/testify/require" +) + +var aLongTimeAgo = time.Unix(233431200, 0) + +var testIdentity = &pmcrypto.Identity{ + Name: "UserID", + Email: "", +} + +const ( + testUsername = "jason" + testAPIPassword = "apple" + + testUID = "729ad6012421d67ad26950dc898bebe3a6e3caa2" //nolint[gosec] + testAccessToken = "de0423049b44243afeec7d9c1d99be7b46da1e8a" //nolint[gosec] + testAccessTokenOld = "feb3159ac63fb05119bcf4480d939278aa746926" //nolint[gosec] + testRefreshToken = "a49b98256745bb497bec20e9b55f5de16f01fb52" //nolint[gosec] + testRefreshTokenNew = "b894b4c4f20003f12d486900d8b88c7d68e67235" //nolint[gosec] +) + +var testAuthInfo = &AuthInfo{ + TwoFA: &TwoFactorInfo{TOTP: 1}, + + version: 4, + salt: "yKlc5/CvObfoiw==", + modulus: "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\nW2z5HBi8RvsfYzZTS7qBaUxxPhsfHJFZpu3Kd6s1JafNrCCH9rfvPLrfuqocxWPgWDH2R8neK7PkNvjxto9TStuY5z7jAzWRvFWN9cQhAKkdWgy0JY6ywVn22+HFpF4cYesHrqFIKUPDMSSIlWjBVmEJZ/MusD44ZT29xcPrOqeZvwtCffKtGAIjLYPZIEbZKnDM1Dm3q2K/xS5h+xdhjnndhsrkwm9U9oyA2wxzSXFL+pdfj2fOdRwuR5nW0J2NFrq3kJjkRmpO/Genq1UW+TEknIWAb6VzJJJA244K/H8cnSx2+nSNZO3bbo6Ys228ruV9A8m6DhxmS+bihN3ttQ==\n-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwl4EARYIABAFAlwB1j0JEDUFhcTpUY8mAAD8CgEAnsFnF4cF0uSHKkXa1GIa\nGO86yMV4zDZEZcDSJo0fgr8A/AlupGN9EdHlsrZLmTA1vhIx+rOgxdEff28N\nkvNM7qIK\n=q6vu\n-----END PGP SIGNATURE-----\n", + srpSession: "9b2946bbd9055f17c34940abdce0c3d3", + serverEphemeral: "5tfigcLKoM0DPWYB+EqYE7QlqsiT63iOVlO5ZX0lTMEILSsrRdVCYrN8L3zkinsAjUZ/cx5wIS7N05k66uZb+ZE3lFOJS2s1BkqLvCrGxYL0e3n5YAnzHYlvCCJKXw/sK57ntfF1OOoblBXX6dw5LjeeDglEep2/DaE0TjD8WUpq4Ls2HlQGn9wrC7dFO2lJXsMhRffxKghiOsdvCLXDmwXginzn/LFezA8KrDsWOBSEGntwpg3s1xFj5h8BqtRHvC0igmoscqgw+3GCMTJ0NZAQ/L+5aJ/0ccL0WBK208ltCNl+/X6Sz0kpyvOP4RqFJhC1auVDJ9AjZQYSYZ1NEQ==", +} + +// testAuth has default values which are adjusted in each test. +var testAuth = &Auth{ + EventID: "NcKPtU5eMNPMrDkIMbEJrgMtC9yQ7Xc5ZBT-tB3UtV1rZ324RWfCIdBI758q0UnsfywS8CkNenIQlWLIX_dUng==", + ExpiresIn: 86400, + RefreshToken: "feb3159ac63fb05119bcf4480d939278aa746926", + Scope: "full mail payments reset keys", + + accessToken: testAccessToken, + uid: testUID, +} + +var testAuth2FA = &Auth2FA{ + Scope: "full mail payments reset keys", +} + +var testAuthRefreshReq = AuthRefreshReq{ + ResponseType: "token", + GrantType: "refresh_token", + RefreshToken: testRefreshToken, + UID: testUID, + RedirectURI: "https://protonmail.ch", + State: "random_string", +} + +var testAuthReq = AuthReq{ + Username: testUsername, + ClientProof: "axfvYdl9iXZjY6zQ+hBYmY7X3TDc/9JtSvrmyZXhDxjxkXB3Hro27t1KItmFIJloItY5sLZDs0eEEZJI34oFZD4ViSG0kfB7ZXcCZ9Jse+U5OFu4vdnPTGolnSofRMEs1NR6ePXzH7mQ10qoq43ity3ve2vmhQNuJNlHAPynKf2WqKOgxq7mmkBzEpXES4mIhwwgVbOygKcUSvguz5E5g13ATF0ZX2d9SJWAbZ262Tks+h99Cdk/dOfgLQhr0nO/r0cpwP84W2RWU2Q34LNkKuuQHkjmxelgBleGq54tCbhoCAYPP6vapgrQjNoVAC/dkjIIAoNL9bJSIynFM5znAA==", + ClientEphemeral: "mK+eSMosfZO/Cs5s+vcbjpsN7F8UAObwlKKnCy/z9FpoMRM2PfTe5ywLBgffmLYaapPq7XOxaqaj08kcZLHcM1fIA2JQZZTKPnESN1qAQztJ3/YHMI0op6yBgzx9803OjIznjCD2B3XBSMOHIG4oG0UwocsIX32hiMnYlMMkt8NGrityPlnmEbxpRna3fu9LEZ+v0uo6PjKCrO7+9E3uaMi64HadXBfyx2raBFFwA+yh7FvE7U+hl3AJclEre4d8pmfhMdxXze1soJI8fMuqaa07rY0r0rF5mLLTuqTIGRFkU1qG9loq9+IMsSwgkt1P3ghW63JK7Y6LWdDy0d6cAg==", + SRPSession: "9b2946bbd9055f17c34940abdce0c3d3", +} + +var testAuth2FAReq = Auth2FAReq{ + TwoFactorCode: "424242", +} + +func init() { + logrus.SetLevel(logrus.DebugLevel) + srp.RandReader = rand.New(rand.NewSource(42)) +} + +func TestClient_AuthInfo(t *testing.T) { + finish, c := newTestServerCallbacks(t, + func(tb testing.TB, w http.ResponseWriter, r *http.Request) string { + Ok(t, checkMethodAndPath(r, "POST", "/auth/info")) + + var infoReq AuthInfoReq + Ok(t, json.NewDecoder(r.Body).Decode(&infoReq)) + Equals(t, infoReq.Username, testUsername) + + return "/auth/info/post_response.json" + }, + ) + defer finish() + + info, err := c.AuthInfo(testCurrentUser.Name) + Ok(t, err) + Equals(t, testAuthInfo, info) +} + +// TestClient_Auth reflects changes from proton/backend-communcation#3. +func TestClient_Auth(t *testing.T) { + srp.RandReader = rand.New(rand.NewSource(42)) + finish, c := newTestServerCallbacks(t, + func(tb testing.TB, w http.ResponseWriter, req *http.Request) string { + a.Nil(t, checkMethodAndPath(req, "POST", "/auth")) + + var authReq AuthReq + r.Nil(t, json.NewDecoder(req.Body).Decode(&authReq)) + r.Equal(t, testAuthReq, authReq) + + return "/auth/post_response.json" + }, + routeGetUsers, + routeGetAddresses, + routeGetSalts, + ) + defer finish() + + auth, err := c.Auth(testUsername, testAPIPassword, testAuthInfo) + r.Nil(t, err) + + r.True(t, c.user.KeyRing().FirstKeyID != "", "Parsing First key ID issue") + + exp := &Auth{} + *exp = *testAuth + exp.accessToken = testAccessToken + exp.RefreshToken = testRefreshToken + exp.KeySalt = "abc" + a.Equal(t, exp, auth) +} + +func TestClient_Auth2FA(t *testing.T) { + finish, c := newTestServerCallbacks(t, + func(tb testing.TB, w http.ResponseWriter, r *http.Request) string { + Ok(t, checkMethodAndPath(r, "POST", "/auth/2fa")) + + var info2FAReq Auth2FAReq + Ok(t, json.NewDecoder(r.Body).Decode(&info2FAReq)) + Equals(t, info2FAReq.TwoFactorCode, testAuth2FAReq.TwoFactorCode) + + return "/auth/2fa/post_response.json" + }, + routeGetUsers, + routeGetAddresses, + routeGetSalts, + ) + defer finish() + + c.uid = testUID + c.accessToken = testAccessToken + auth2FA, err := c.Auth2FA(testAuth2FAReq.TwoFactorCode, testAuth) + Ok(t, err) + + Equals(t, testAuth2FA, auth2FA) +} + +func TestClient_Auth2FA_Fail(t *testing.T) { + finish, c := newTestServerCallbacks(t, + func(tb testing.TB, w http.ResponseWriter, r *http.Request) string { + Ok(t, checkMethodAndPath(r, "POST", "/auth/2fa")) + + var info2FAReq Auth2FAReq + Ok(t, json.NewDecoder(r.Body).Decode(&info2FAReq)) + Equals(t, info2FAReq.TwoFactorCode, testAuth2FAReq.TwoFactorCode) + + return "/auth/2fa/post_401_bad_password.json" + }, + ) + defer finish() + + c.uid = testUID + c.accessToken = testAccessToken + _, err := c.Auth2FA(testAuth2FAReq.TwoFactorCode, testAuth) + Equals(t, ErrBad2FACode, err) +} + +func TestClient_Auth2FA_Retry(t *testing.T) { + finish, c := newTestServerCallbacks(t, + func(tb testing.TB, w http.ResponseWriter, r *http.Request) string { + Ok(t, checkMethodAndPath(r, "POST", "/auth/2fa")) + + var info2FAReq Auth2FAReq + Ok(t, json.NewDecoder(r.Body).Decode(&info2FAReq)) + Equals(t, info2FAReq.TwoFactorCode, testAuth2FAReq.TwoFactorCode) + + return "/auth/2fa/post_422_bad_password.json" + }, + ) + defer finish() + + c.uid = testUID + c.accessToken = testAccessToken + _, err := c.Auth2FA(testAuth2FAReq.TwoFactorCode, testAuth) + Equals(t, ErrBad2FACodeTryAgain, err) +} + +func TestClient_Unlock(t *testing.T) { + finish, c := newTestServerCallbacks(t, + routeGetUsers, + routeGetAddresses, + ) + defer finish() + c.uid = testUID + c.accessToken = testAccessToken + + _, err := c.Unlock("wrong") + a.True(t, IsUnlockError(err), "expected error, pasword is wrong") + + _, err = c.Unlock(testMailboxPassword) + a.Nil(t, err) + a.Equal(t, testUID, c.uid) + a.Equal(t, testAccessToken, c.accessToken) + + // second try should not fail because there is an unlocked key already + _, err = c.Unlock("wrong") + a.Nil(t, err) +} + +func TestClient_Unlock_EncPrivKey(t *testing.T) { + finish, c := newTestServerCallbacks(t, + routeGetUsers, + routeGetAddresses, + ) + defer finish() + c.uid = testUID + c.accessToken = testAccessToken + + _, err := c.Unlock(testMailboxPassword) + Ok(t, err) + Equals(t, testUID, c.uid) + Equals(t, testAccessToken, c.accessToken) +} + +func routeAuthRefresh(tb testing.TB, w http.ResponseWriter, r *http.Request) string { + Ok(tb, checkMethodAndPath(r, "POST", "/auth/refresh")) + Ok(tb, checkHeader(r.Header, "x-pm-uid", testUID)) + + var refreshReq AuthRefreshReq + Ok(tb, json.NewDecoder(r.Body).Decode(&refreshReq)) + Equals(tb, testAuthRefreshReq, refreshReq) + + return "/auth/refresh/post_response.json" +} + +// TestClient_AuthRefresh reflects changes from proton/backend-communcation#11. +func TestClient_AuthRefresh(t *testing.T) { + finish, c := newTestServerCallbacks(t, + routeAuthRefresh, + ) + defer finish() + c.uid = "" // Testing that we always send correct `x-pm-uid`. + c.accessToken = "oldToken" + + auth, err := c.AuthRefresh(testUID + ":" + testRefreshToken) + Ok(t, err) + + exp := &Auth{} + *exp = *testAuth + exp.accessToken = testAccessToken + exp.KeySalt = "" + exp.EventID = "" + exp.ExpiresIn = 360000 + exp.RefreshToken = testRefreshTokenNew + Equals(t, exp, auth) +} + +func routeAuthRefreshHasUID(tb testing.TB, w http.ResponseWriter, r *http.Request) string { + Ok(tb, checkMethodAndPath(r, "POST", "/auth/refresh")) + Ok(tb, checkHeader(r.Header, "x-pm-uid", testUID)) + + var refreshReq AuthRefreshReq + Ok(tb, json.NewDecoder(r.Body).Decode(&refreshReq)) + Equals(tb, testAuthRefreshReq, refreshReq) + + return "/auth/refresh/post_resp_has_uid.json" +} + +// TestClient_AuthRefresh reflects changes from proton/backend-communcation#3. +func TestClient_AuthRefresh_HasUID(t *testing.T) { + finish, c := newTestServerCallbacks(t, + routeAuthRefreshHasUID, + ) + defer finish() + c.uid = testUID + c.accessToken = "oldToken" + + auth, err := c.AuthRefresh(testUID + ":" + testRefreshToken) + Ok(t, err) + + exp := &Auth{} + *exp = *testAuth + exp.accessToken = testAccessToken + exp.KeySalt = "" + exp.EventID = "" + exp.ExpiresIn = 360000 + exp.RefreshToken = testRefreshTokenNew + Equals(t, exp, auth) +} + +func TestClient_Logout(t *testing.T) { + finish, c := newTestServerCallbacks(t, + func(tb testing.TB, w http.ResponseWriter, r *http.Request) string { + Ok(t, checkMethodAndPath(r, "DELETE", "/auth")) + Ok(t, isAuthReq(r, testUID, testAccessToken)) + return "auth/delete_response.json" + }, + ) + defer finish() + c.uid = testUID + c.accessToken = testAccessToken + + Ok(t, c.Logout()) +} + +func TestClient_DoUnauthorized(t *testing.T) { + finish, c := newTestServerCallbacks(t, + func(tb testing.TB, w http.ResponseWriter, r *http.Request) string { + Ok(t, checkMethodAndPath(r, "GET", "/")) + return httpResponse(http.StatusUnauthorized) + }, + routeAuthRefresh, + func(tb testing.TB, w http.ResponseWriter, r *http.Request) string { + Ok(t, checkMethodAndPath(r, "GET", "/")) + Ok(t, isAuthReq(r, testUID, testAccessToken)) + return httpResponse(http.StatusOK) + }, + ) + defer finish() + + c.uid = testUID + c.accessToken = testAccessTokenOld + c.expiresAt = aLongTimeAgo + c.tokenManager = NewTokenManager() + c.tokenManager.tokenMap[c.userID] = testUID + ":" + testRefreshToken + + req, err := NewRequest("GET", "/", nil) + Ok(t, err) + + res, err := c.Do(req, true) + Ok(t, err) + + defer Ok(t, res.Body.Close()) +} diff --git a/pkg/pmapi/auth_test_export.go b/pkg/pmapi/auth_test_export.go new file mode 100644 index 00000000..de1efaad --- /dev/null +++ b/pkg/pmapi/auth_test_export.go @@ -0,0 +1,23 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +// DANGEROUSLYSetUID SHOULD NOT be used!!! This is only for testing purposes. +func (s *Auth) DANGEROUSLYSetUID(uid string) { + s.uid = uid +} diff --git a/pkg/pmapi/bugs.go b/pkg/pmapi/bugs.go new file mode 100644 index 00000000..bce49787 --- /dev/null +++ b/pkg/pmapi/bugs.go @@ -0,0 +1,217 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "archive/zip" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "runtime" + "strings" +) + +// ClientType is required by API. +const ( + EmailClientType = iota + 1 + VPNClientType +) + +type reportAtt struct { + name, filename string + body io.Reader +} + +// ReportReq stores data for report. +type ReportReq struct { + OS string `json:",omitempty"` + OSVersion string `json:",omitempty"` + Browser string `json:",omitempty"` + BrowserVersion string `json:",omitempty"` + BrowserExtensions string `json:",omitempty"` + Resolution string `json:",omitempty"` + DisplayMode string `json:",omitempty"` + Client string `json:",omitempty"` + ClientVersion string `json:",omitempty"` + ClientType int `json:",omitempty"` + Title string `json:",omitempty"` + Description string `json:",omitempty"` + Username string `json:",omitempty"` + Email string `json:",omitempty"` + Country string `json:",omitempty"` + ISP string `json:",omitempty"` + Debug string `json:",omitempty"` + Attachments []reportAtt `json:",omitempty"` +} + +// AddAttachment to report. +func (rep *ReportReq) AddAttachment(name, filename string, r io.Reader) { + rep.Attachments = append(rep.Attachments, reportAtt{name: name, filename: filename, body: r}) +} + +func writeMultipartReport(w *multipart.Writer, rep *ReportReq) error { // nolint[funlen] + fieldData := map[string]string{ + "OS": rep.OS, + "OSVersion": rep.OSVersion, + "Browser": rep.Browser, + "BrowserVersion": rep.BrowserVersion, + "BrowserExtensions": rep.BrowserExtensions, + "Resolution": rep.Resolution, + "DisplayMode": rep.DisplayMode, + "Client": rep.Client, + "ClientVersion": rep.ClientVersion, + "ClientType": "1", + "Title": rep.Title, + "Description": rep.Description, + "Username": rep.Username, + "Email": rep.Email, + "Country": rep.Country, + "ISP": rep.ISP, + "Debug": rep.Debug, + } + + for field, data := range fieldData { + if data == "" { + continue + } + if err := w.WriteField(field, data); err != nil { + return err + } + } + + quoteEscaper := strings.NewReplacer("\\", "\\\\", `"`, "\\\"") + + for _, att := range rep.Attachments { + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", + fmt.Sprintf(`form-data; name="%s"; filename="%s"`, + quoteEscaper.Replace(att.name), quoteEscaper.Replace(att.filename+".zip"))) + h.Set("Content-Type", "application/octet-stream") + //h.Set("Content-Transfere-Encoding", "base64") + attWr, err := w.CreatePart(h) + if err != nil { + return err + } + + zipArch := zip.NewWriter(attWr) + zipWr, err := zipArch.Create(att.filename) + //b64 := base64.NewEncoder(base64.StdEncoding, zipWr) + if err != nil { + return err + } + _, err = io.Copy(zipWr, att.body) + if err != nil { + return err + } + err = zipArch.Close() + //err = b64.Close() + if err != nil { + return err + } + } + + return nil +} + +// Report sends request as json or multipart (if has attachment). +func (c *Client) Report(rep ReportReq) (err error) { + rep.Client = c.config.ClientID + rep.ClientVersion = c.config.AppVersion + rep.ClientType = EmailClientType + + var req *http.Request + var w *MultipartWriter + if len(rep.Attachments) > 0 { + req, w, err = NewMultipartRequest("POST", "/reports/bug") + } else { + req, err = NewJSONRequest("POST", "/reports/bug", rep) + } + if err != nil { + return + } + + var res Res + done := make(chan error, 1) + go func() { + done <- c.DoJSON(req, &res) + }() + + if w != nil { + err = writeMultipartReport(w.Writer, &rep) + if err != nil { + c.log.Errorln("report write: ", err) + return + } + err = w.Close() + if err != nil { + c.log.Errorln("report close: ", err) + return + } + } + + if err = <-done; err != nil { + return + } + + return res.Err() +} + +// ReportBug is old. Use Report instead. +func (c *Client) ReportBug(os, osVersion, title, description, username, email string) (err error) { + return c.ReportBugWithEmailClient(os, osVersion, title, description, username, email, "") +} + +// ReportBugWithEmailClient is old. Use Report instead. +func (c *Client) ReportBugWithEmailClient(os, osVersion, title, description, username, email, emailClient string) (err error) { + bugReq := ReportReq{ + OS: os, + OSVersion: osVersion, + Browser: emailClient, + Title: title, + Description: description, + Username: username, + Email: email, + } + + return c.Report(bugReq) +} + +// ReportCrash is old. Use sentry instead. +func (c *Client) ReportCrash(stacktrace string) (err error) { + crashReq := ReportReq{ + Client: c.config.ClientID, + ClientVersion: c.config.AppVersion, + ClientType: EmailClientType, + OS: runtime.GOOS, + Debug: stacktrace, + } + req, err := NewJSONRequest("POST", "/reports/crash", crashReq) + if err != nil { + return + } + + var res Res + if err = c.DoJSON(req, &res); err != nil { + return + } + + err = res.Err() + return +} diff --git a/pkg/pmapi/bugs_test.go b/pkg/pmapi/bugs_test.go new file mode 100644 index 00000000..c3336c20 --- /dev/null +++ b/pkg/pmapi/bugs_test.go @@ -0,0 +1,180 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "runtime" + "strings" + "testing" +) + +var testBugsReportReq = ReportReq{ + OS: "Mac OSX", + OSVersion: "10.11.6", + Client: "demoapp", + ClientVersion: "GoPMAPI_1.0.14", + ClientType: 1, + Title: "Big Bug", + Description: "Cannot fetch new messages", + Username: "apple", + Email: "apple@gmail.com", +} + +var testBugsReportReqWithEmailClient = ReportReq{ + OS: "Mac OSX", + OSVersion: "10.11.6", + Browser: "AppleMail", + Client: "demoapp", + ClientVersion: "GoPMAPI_1.0.14", + ClientType: 1, + Title: "Big Bug", + Description: "Cannot fetch new messages", + Username: "Apple", + Email: "apple@gmail.com", +} + +var testBugsCrashReq = ReportReq{ + OS: runtime.GOOS, + Client: "demoapp", + ClientVersion: "GoPMAPI_1.0.14", + ClientType: 1, + Debug: "main.func·001()\n/Users/sunny/Code/Go/src/scratch/stack.go:21 +0xabruntime.panic(0x80b80, 0x2101fb150)\n/usr/local/Cellar/go/1.2/libexec/src/pkg/runtime/panic.c:248 +0x106\nmain.inner()/Users/sunny/Code/Go/src/scratch/stack.go:27 +0x68\nmain.outer()\n/Users/sunny/Code/Go/src/scratch/stack.go:13 +0x1a\nmain.main()\n/Users/sunny/Code/Go/src/scratch/stack.go:9 +0x1a", +} + +const testBugsBody = `{ + "Code": 1000 +} +` + +const testAttachmentJSONZipped = "PK\x03\x04\x14\x00\b\x00\b\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\b\x00\x00\x00last.log\\Rَ\xaaH\x00}ﯨ\xf8r\x1f\xeeܖED;\xe9\ap\x03\x11\x11\x97\x0e8\x99L\xb0(\xa1\xa0\x16\x85b\x91I\xff\xfbD{\x99\xc9}\xab:K\x9d\xa4\xce\xf9\xe7\t\x00\x00z\xf6\xb4\xf7\x02z\xb7a\xe5\xd8\x04*V̭\x8d\xd1lvE}\xd6\xe3\x80\x1f\xd7nX\x9bI[\xa6\xe1a=\xd4a\xa8M\x97\xd9J\xf1F\xeb\x105U\xbd\xb0`XO\xce\xf1hu\x99q\xc3\xfe{\x11ߨ'-\v\x89Z\xa4\x9c5\xaf\xaf\xbd?>R\xd6\x11E\xf7\x1cX\xf0JpF#L\x9eE+\xbe\xe8\x1d\xee\ued2e\u007f\xde]\u06dd\xedo\x97\x87E\xa0V\xf4/$\xc2\xecK\xed\xa0\xdb&\x829\x12\xe5\x9do\xa0\xe9\x1a\xd2\x19\x1e\xf5`\x95гb\xf8\x89\x81\xb7\xa5G\x18\x95\xf3\x9d9\xe8\x93B\x17!\x1a^\xccr\xbb`\xb2\xb4\xb86\x87\xb4h\x0e\xda\xc6u<+\x9e$̓\x95\xccSo\xea\xa4\xdbH!\xe9g\x8b\xd4\b\xb3hܬ\xa6Wk\x14He\xae\x8aPU\xaa\xc1\xee$\xfbH\xb3\xab.I\f<\x89\x06q\xe3-3-\x99\xcdݽ\xe5v\x99\xedn\xac\xadn\xe8Rp=\xb4nJ\xed\xd5\r\x8d\xde\x06Ζ\xf6\xb3\x01\x94\xcb\xf6\xd4\x19r\xe1\xaa$4+\xeaW\xa6F\xfa0\x97\x9cD\f\x8e\xd7\xd6z\v,G\xf3e2\xd4\xe6V\xba\v\xb6\xd9\xe8\xca*\x16\x95V\xa4J\xfbp\xddmF\x8c\x9a\xc6\xc8Č-\xdb\v\xf6\xf5\xf9\x02*\x15e\x874\xc9\xe7\"\xa3\x1an\xabq}ˊq\x957\xd3\xfd\xa91\x82\xe0Lß\\\x17\x8e\x9e_\xed`\t\xe9~5̕\x03\x9a\f\xddN6\xa2\xc4\x17\xdb\xc9V\x1c~\x9e\xea\xbe\xda-xv\xed\x8b\xe2\xc8DŽS\x95E6\xf2\xc3H\x1d:HPx\xc9\x14\xbfɒ\xff\xea\xb4P\x14\xa3\xe2\xfe\xfd\x1f+z\x80\x903\x81\x98\xf8\x15\xa3\x12\x16\xf8\"0g\xf7~B^\xfd \x040T\xa3\x02\x9c\x10\xc1\xa8F\xa0I#\xf1\xa3\x04\x98\x01\x91\xe2\x12\xdc;\x06gL\xd0g\xc0\xe3\xbd\xf6\xd7}&\xa8轀?\xbfяy`X\xf0\x92\x9f\x05\xf0*A8ρ\xac=K\xff\xf3\xfe\xa6Z\xe1\x1a\x017\xc2\x04\f\x94g\xa9\xf7-\xfb\xebqz\u007fz\u007f\xfa7\x00\x00\xff\xffPK\a\b\xf5\\\v\xe5I\x02\x00\x00\r\x03\x00\x00PK\x01\x02\x14\x00\x14\x00\b\x00\b\x00\x00\x00\x00\x00\xf5\\\v\xe5I\x02\x00\x00\r\x03\x00\x00\b\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00last.logPK\x05\x06\x00\x00\x00\x00\x01\x00\x01\x006\x00\x00\x00\u007f\x02\x00\x00\x00\x00" //nolint[misspell] + +func TestClient_BugReport(t *testing.T) { + s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Ok(t, checkMethodAndPath(r, "POST", "/reports/bug")) + Ok(t, isAuthReq(r, testUID, testAccessToken)) + + var bugsReportReq ReportReq + Ok(t, json.NewDecoder(r.Body).Decode(&bugsReportReq)) + Equals(t, testBugsReportReq, bugsReportReq) + + fmt.Fprint(w, testBugsBody) + })) + defer s.Close() + c.uid = testUID + c.accessToken = testAccessToken + + Ok(t, c.ReportBug( + testBugsReportReq.OS, + testBugsReportReq.OSVersion, + testBugsReportReq.Title, + testBugsReportReq.Description, + testBugsReportReq.Username, + testBugsReportReq.Email, + )) +} + +func TestClient_BugReportWithAttachment(t *testing.T) { + s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Ok(t, checkMethodAndPath(r, "POST", "/reports/bug")) + Ok(t, isAuthReq(r, testUID, testAccessToken)) + + Ok(t, r.ParseMultipartForm(10*1024)) + + for field, expected := range map[string]string{ + "OS": testBugsReportReq.OS, + "OSVersion": testBugsReportReq.OSVersion, + "Client": testBugsReportReq.Client, + "ClientVersion": testBugsReportReq.ClientVersion, + "ClientType": fmt.Sprintf("%d", testBugsReportReq.ClientType), + "Title": testBugsReportReq.Title, + "Description": testBugsReportReq.Description, + "Username": testBugsReportReq.Username, + "Email": testBugsReportReq.Email, + } { + if r.PostFormValue(field) != expected { + t.Errorf("Field %q has %q but expected %q", field, r.PostFormValue(field), expected) + } + } + + attReader, err := r.MultipartForm.File["log"][0].Open() + Ok(t, err) + + log, err := ioutil.ReadAll(attReader) + Ok(t, err) + + Equals(t, []byte(testAttachmentJSONZipped), log) + + fmt.Fprint(w, testBugsBody) + })) + defer s.Close() + c.uid = testUID + c.accessToken = testAccessToken + + rep := testBugsReportReq + rep.AddAttachment("log", "last.log", strings.NewReader(testAttachmentJSON)) + + Ok(t, c.Report(rep)) +} + +func TestClient_BugReportWithEmailClient(t *testing.T) { + s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Ok(t, checkMethodAndPath(r, "POST", "/reports/bug")) + Ok(t, isAuthReq(r, testUID, testAccessToken)) + + var bugsReportReq ReportReq + Ok(t, json.NewDecoder(r.Body).Decode(&bugsReportReq)) + Equals(t, testBugsReportReqWithEmailClient, bugsReportReq) + + fmt.Fprint(w, testBugsBody) + })) + defer s.Close() + c.uid = testUID + c.accessToken = testAccessToken + + Ok(t, c.ReportBugWithEmailClient( + testBugsReportReqWithEmailClient.OS, + testBugsReportReqWithEmailClient.OSVersion, + testBugsReportReqWithEmailClient.Title, + testBugsReportReqWithEmailClient.Description, + testBugsReportReqWithEmailClient.Username, + testBugsReportReqWithEmailClient.Email, + testBugsReportReqWithEmailClient.Browser, + )) +} + +func TestClient_BugsCrash(t *testing.T) { + s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Ok(t, checkMethodAndPath(r, "POST", "/reports/crash")) + Ok(t, isAuthReq(r, testUID, testAccessToken)) + + var bugsCrashReq ReportReq + Ok(t, json.NewDecoder(r.Body).Decode(&bugsCrashReq)) + Equals(t, testBugsCrashReq, bugsCrashReq) + + fmt.Fprint(w, testBugsBody) + })) + defer s.Close() + c.uid = testUID + c.accessToken = testAccessToken + + Ok(t, c.ReportCrash(testBugsCrashReq.Debug)) +} diff --git a/pkg/pmapi/client.go b/pkg/pmapi/client.go new file mode 100644 index 00000000..ef96639d --- /dev/null +++ b/pkg/pmapi/client.go @@ -0,0 +1,503 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "math/rand" + "net/http" + "reflect" + "strconv" + "strings" + "sync" + "time" + + pmcrypto "github.com/ProtonMail/gopenpgp/crypto" + "github.com/jaytaylor/html2text" + "github.com/sirupsen/logrus" +) + +// Version of the API. +const Version = 3 + +// API return codes. +const ( + ForceUpgradeBadAPIVersion = 5003 + ForceUpgradeInvalidAPI = 5004 + ForceUpgradeBadAppVersion = 5005 + APIOffline = 7001 + ImportMessageTooLong = 36022 + BansRequests = 85131 +) + +// The output errors. +var ( + ErrInvalidToken = errors.New("refresh token invalid") + ErrAPINotReachable = errors.New("cannot reach the server") + ErrUpgradeApplication = errors.New("application upgrade required") +) + +type ErrUnauthorized struct { + error +} + +func (err *ErrUnauthorized) Error() string { + return fmt.Sprintf("unauthorized access: %+v", err.error.Error()) +} + +type TokenManager struct { + tokensLocker sync.Locker + tokenMap map[string]string +} + +func NewTokenManager() *TokenManager { + tm := &TokenManager{ + tokensLocker: &sync.Mutex{}, + tokenMap: map[string]string{}, + } + return tm +} + +func (tm *TokenManager) GetToken(userID string) string { + tm.tokensLocker.Lock() + defer tm.tokensLocker.Unlock() + + return tm.tokenMap[userID] +} + +func (tm *TokenManager) SetToken(userID, token string) { + tm.tokensLocker.Lock() + defer tm.tokensLocker.Unlock() + + tm.tokenMap[userID] = token +} + +// ClientConfig contains Client configuration. +type ClientConfig struct { + // The client application name and version. + AppVersion string + + // The client ID. + ClientID string + + TokenManager *TokenManager + + // Transport specifies the mechanism by which individual HTTP requests are made. + // If nil, http.DefaultTransport is used. + Transport http.RoundTripper + + // Timeout specifies the timeout from request to getting response headers to our API. + // Passed to http.Client, empty means no timeout. + Timeout time.Duration + + // FirstReadTimeout specifies the timeout from getting response to the first read of body response. + // This timeout is applied only when MinSpeed is used. + // Default is 5 minutes. + FirstReadTimeout time.Duration + + // MinSpeed specifies minimum Bytes per second or the request will be canceled. + // Zero means no limitation. + MinSpeed int64 +} + +// Client to communicate with API. +type Client struct { + auths chan<- *Auth // Channel that sends Auth responses back to the bridge. + + log *logrus.Entry + config *ClientConfig + client *http.Client + conrep ConnectionReporter + + uid string + accessToken string + userID string // Twice here because Username is not unique. + requestLocker sync.Locker + keyLocker sync.Locker + + tokenManager *TokenManager + expiresAt time.Time + user *User + addresses AddressList + kr *pmcrypto.KeyRing +} + +// NewClient creates a new API client. +func NewClient(cfg *ClientConfig, userID string) *Client { + hc := &http.Client{ + Timeout: cfg.Timeout, + } + if cfg.Transport != nil { + cfgTransport, ok := cfg.Transport.(*http.Transport) + if ok { + // In future use Clone here. + // https://go-review.googlesource.com/c/go/+/174597/ + transport := &http.Transport{} + *transport = *cfgTransport //nolint + if transport.Proxy == nil { + transport.Proxy = http.ProxyFromEnvironment + } + hc.Transport = transport + } else { + hc.Transport = cfg.Transport + } + } else if defaultTransport != nil { + hc.Transport = defaultTransport + } + + log := logrus.WithFields(logrus.Fields{ + "pkg": "pmapi", + "userID": userID, + }) + + return &Client{ + log: log, + config: cfg, + client: hc, + tokenManager: cfg.TokenManager, + userID: userID, + requestLocker: &sync.Mutex{}, + keyLocker: &sync.Mutex{}, + } +} + +// SetConnectionReporter sets the connection reporter used by the client to report when +// internet connection is lost. +func (c *Client) SetConnectionReporter(conrep ConnectionReporter) { + c.conrep = conrep +} + +// reportLostConnection reports that the internet connection has been lost using the connection reporter. +// If the connection reporter has not been set, this does nothing. +func (c *Client) reportLostConnection() { + if c.conrep != nil { + err := c.conrep.NotifyConnectionLost() + if err != nil { + logrus.WithError(err).Error("Failed to notify of lost connection") + } + } +} + +// Do makes an API request. It does not check for HTTP status code errors. +func (c *Client) Do(req *http.Request, retryUnauthorized bool) (res *http.Response, err error) { + // Copy the request body in case we need to retry it. + var bodyBuffer []byte + if req.Body != nil { + defer req.Body.Close() //nolint[errcheck] + bodyBuffer, err = ioutil.ReadAll(req.Body) + + if err != nil { + return nil, err + } + + r := bytes.NewReader(bodyBuffer) + req.Body = ioutil.NopCloser(r) + } + + return c.doBuffered(req, bodyBuffer, retryUnauthorized) +} + +// If needed it retries using req and buffered body. +func (c *Client) doBuffered(req *http.Request, bodyBuffer []byte, retryUnauthorized bool) (res *http.Response, err error) { // nolint[funlen] + isAuthReq := strings.Contains(req.URL.Path, "/auth") + + req.Header.Set("x-pm-appversion", c.config.AppVersion) + req.Header.Set("x-pm-apiversion", strconv.Itoa(Version)) + + if c.uid != "" { + req.Header.Set("x-pm-uid", c.uid) + } + + if c.accessToken != "" { + req.Header.Set("Authorization", "Bearer "+c.accessToken) + } + + c.log.Debugln("Requesting ", req.Method, req.URL.RequestURI()) + if logrus.GetLevel() == logrus.TraceLevel { + head := "" + for i, v := range req.Header { + head += i + ": " + head += strings.Join(v, "") + head += "\n" + } + c.log.Tracef("REQHEAD \n%s", head) + c.log.Tracef("REQBODY '%s'", string(bodyBuffer)) + } + + hasBody := len(bodyBuffer) > 0 + if res, err = c.client.Do(req); err != nil { + if res == nil { + c.log.WithError(err).Error("Cannot get response") + err = ErrAPINotReachable + c.reportLostConnection() + } + return + } + + resDate := res.Header.Get("Date") + if resDate != "" { + if serverTime, err := http.ParseTime(resDate); err == nil { + pmcrypto.GetGopenPGP().UpdateTime(serverTime.Unix()) + } + } + + if res.StatusCode == http.StatusUnauthorized { + if hasBody { + r := bytes.NewReader(bodyBuffer) + req.Body = ioutil.NopCloser(r) + } + + if !isAuthReq { + _, _ = io.Copy(ioutil.Discard, res.Body) + _ = res.Body.Close() + return c.handleStatusUnauthorized(req, bodyBuffer, res, retryUnauthorized) + } + } + + // Retry induced by HTTP status code> + retryAfter := 10 + doRetry := res.StatusCode == http.StatusTooManyRequests + if doRetry { + if headerAfter, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil && headerAfter > 0 { + retryAfter = headerAfter + } + // To avoid spikes when all clients retry at the same time, we add some random wait. + retryAfter += rand.Intn(10) + + if hasBody { + r := bytes.NewReader(bodyBuffer) + req.Body = ioutil.NopCloser(r) + } + + c.log.Warningf("Retrying %s after %ds induced by http code %d", req.URL.Path, retryAfter, res.StatusCode) + time.Sleep(time.Duration(retryAfter) * time.Second) + _, _ = io.Copy(ioutil.Discard, res.Body) + _ = res.Body.Close() + return c.doBuffered(req, bodyBuffer, false) + } + + return res, err +} + +// DoJSON performs the request and unmarshals the response as JSON into data. +// If the API returns a non-2xx HTTP status code, the error returned will contain status +// and response as plaintext. API errors must be checked by the caller. +// It is performed buffered, in case we need to retry. +func (c *Client) DoJSON(req *http.Request, data interface{}) error { + // Copy the request body in case we need to retry it + var reqBodyBuffer []byte + + if req.Body != nil { + defer req.Body.Close() //nolint[errcheck] + var err error + if reqBodyBuffer, err = ioutil.ReadAll(req.Body); err != nil { + return err + } + + req.Body = ioutil.NopCloser(bytes.NewReader(reqBodyBuffer)) + } + + return c.doJSONBuffered(req, reqBodyBuffer, data) +} + +// doJSONBuffered performs a buffered json request (see DoJSON for more information). +func (c *Client) doJSONBuffered(req *http.Request, reqBodyBuffer []byte, data interface{}) error { // nolint[funlen] + req.Header.Set("Accept", "application/vnd.protonmail.v1+json") + + var cancelRequest context.CancelFunc + if c.config.MinSpeed > 0 { + var ctx context.Context + ctx, cancelRequest = context.WithCancel(req.Context()) + defer func() { + cancelRequest() + }() + req = req.WithContext(ctx) + } + + res, err := c.doBuffered(req, reqBodyBuffer, false) + if err != nil { + return err + } + defer res.Body.Close() //nolint[errcheck] + + var resBody []byte + if c.config.MinSpeed == 0 { + resBody, err = ioutil.ReadAll(res.Body) + } else { + resBody, err = c.readAllMinSpeed(res.Body, cancelRequest) + } + + // The server response may contain data which we want to have in memory + // for as little time as possible (such as keys). Go is garbage collected, + // so we are not in charge of when the memory will actually be cleared. + // We can at least try to rewrite the original data to mitigate this problem. + defer func() { + for i := 0; i < len(resBody); i++ { + resBody[i] = byte(65) + } + }() + + if logrus.GetLevel() == logrus.TraceLevel { + head := "" + for i, v := range res.Header { + head += i + ": " + head += strings.Join(v, "") + head += "\n" + } + c.log.Tracef("RESHEAD \n%s", head) + c.log.Tracef("RESBODY '%s'", resBody) + } + + if err != nil { + return err + } + + // Retry induced by API code. + errCode := &Res{} + if err := json.Unmarshal(resBody, errCode); err == nil { + if errCode.Code == BansRequests { + retryAfter := 3 + c.log.Warningf("Retrying %s after %ds induced by API code %d", req.URL.Path, retryAfter, errCode.Code) + time.Sleep(time.Duration(retryAfter) * time.Second) + if len(reqBodyBuffer) > 0 { + req.Body = ioutil.NopCloser(bytes.NewReader(reqBodyBuffer)) + } + return c.doJSONBuffered(req, reqBodyBuffer, data) + } + } + + if err := json.Unmarshal(resBody, data); err != nil { + // Check to see if this is due to a non 2xx HTTP status code. + if res.StatusCode != http.StatusOK { + r := bytes.NewReader(bytes.ReplaceAll(resBody, []byte("\n"), []byte("\\n"))) + plaintext, err := html2text.FromReader(r) + if err == nil { + return fmt.Errorf("Error: \n\n" + res.Status + "\n\n" + plaintext) + } + } + + if errJS, ok := err.(*json.SyntaxError); ok { + return fmt.Errorf("invalid json %v (offset:%d) ", errJS.Error(), errJS.Offset) + } + + return fmt.Errorf("unmarshal fail: %v ", err) + } + + // Set StatusCode in case data struct supports that field. + // It's safe to set StatusCode, server returns Code. StatusCode should be preferred over Code. + dataValue := reflect.ValueOf(data).Elem() + statusCodeField := dataValue.FieldByName("StatusCode") + if statusCodeField.IsValid() && statusCodeField.CanSet() && statusCodeField.Kind() == reflect.Int { + statusCodeField.SetInt(int64(res.StatusCode)) + } + + if res.StatusCode != http.StatusOK { + c.log.Warnf("request %s %s NOT OK: %s", req.Method, req.URL.Path, res.Status) + } + + return nil +} + +func (c *Client) readAllMinSpeed(data io.Reader, cancelRequest context.CancelFunc) ([]byte, error) { + firstReadTimeout := c.config.FirstReadTimeout + if firstReadTimeout == 0 { + firstReadTimeout = 5 * time.Minute + } + timer := time.AfterFunc(firstReadTimeout, func() { + cancelRequest() + }) + var buffer bytes.Buffer + for { + _, err := io.CopyN(&buffer, data, c.config.MinSpeed) + timer.Stop() + timer.Reset(1 * time.Second) + if err == io.EOF { + break + } else if err != nil { + return nil, err + } + } + return ioutil.ReadAll(&buffer) +} + +func (c *Client) refreshAccessToken() (err error) { + c.log.Debug("Refreshing token") + refreshToken := c.tokenManager.GetToken(c.userID) + c.log.WithField("token", refreshToken).Info("Current refresh token") + if refreshToken == "" { + if c.auths != nil { + c.auths <- nil + } + if c.tokenManager != nil { + c.tokenManager.SetToken(c.userID, "") + } + return ErrInvalidToken + } + + auth, err := c.AuthRefresh(refreshToken) + if err != nil { + c.log.WithError(err).WithField("auths", c.auths).Debug("Token refreshing failed") + // The refresh failed, so we should log the user out. + // A nil value in the Auths channel will trigger this. + if c.auths != nil { + c.auths <- nil + } + if c.tokenManager != nil { + c.tokenManager.SetToken(c.userID, "") + } + return + } + c.uid = auth.UID() + c.accessToken = auth.accessToken + return err +} + +func (c *Client) handleStatusUnauthorized(req *http.Request, reqBodyBuffer []byte, res *http.Response, retry bool) (retryRes *http.Response, err error) { + c.log.Info("Handling unauthorized status") + + // If this is not a retry, then it is the first time handling status unauthorized, + // so try again without refreshing the access token. + if !retry { + c.log.Debug("Handling unauthorized status by retrying") + c.requestLocker.Lock() + defer c.requestLocker.Unlock() + + _, _ = io.Copy(ioutil.Discard, res.Body) + _ = res.Body.Close() + return c.doBuffered(req, reqBodyBuffer, true) + } + + // This is already a retry, so we will try to refresh the access token before trying again. + if err = c.refreshAccessToken(); err != nil { + c.log.WithError(err).Warn("Cannot refresh token") + err = &ErrUnauthorized{err} + return + } + _, err = io.Copy(ioutil.Discard, res.Body) + if err != nil { + c.log.WithError(err).Warn("Failed to read out response body") + } + _ = res.Body.Close() + return c.doBuffered(req, reqBodyBuffer, true) +} diff --git a/pkg/pmapi/client_test.go b/pkg/pmapi/client_test.go new file mode 100644 index 00000000..1907e6d9 --- /dev/null +++ b/pkg/pmapi/client_test.go @@ -0,0 +1,215 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "context" + "fmt" + "io" + "io/ioutil" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +var testClientConfig = &ClientConfig{ + AppVersion: "GoPMAPI_1.0.14", + ClientID: "demoapp", + FirstReadTimeout: 500 * time.Millisecond, + MinSpeed: 256, +} + +func newTestClient() *Client { + c := NewClient(testClientConfig, "tester") + c.tokenManager = NewTokenManager() + return c +} + +func TestClient_Do(t *testing.T) { + const testResBody = "Hello World!" + + var receivedReq *http.Request + s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedReq = r + fmt.Fprint(w, testResBody) + })) + defer s.Close() + + req, err := NewRequest("GET", "/", nil) + if err != nil { + t.Fatal("Expected no error while creating request, got:", err) + } + + res, err := c.Do(req, true) + if err != nil { + t.Fatal("Expected no error while executing request, got:", err) + } + + b, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Fatal("Expected no error while reading response, got:", err) + } + require.Nil(t, res.Body.Close()) + + if string(b) != testResBody { + t.Fatalf("Invalid response body: expected %v, got %v", testResBody, string(b)) + } + + h := receivedReq.Header + if h.Get("x-pm-appversion") != testClientConfig.AppVersion { + t.Fatalf("Invalid app version header: expected %v, got %v", testClientConfig.AppVersion, h.Get("x-pm-appversion")) + } + if h.Get("x-pm-apiversion") != fmt.Sprintf("%v", Version) { + t.Fatalf("Invalid api version header: expected %v, got %v", Version, h.Get("x-pm-apiversion")) + } + if h.Get("x-pm-uid") != "" { + t.Fatalf("Expected no uid header when not authenticated, got %v", h.Get("x-pm-uid")) + } + if h.Get("Authorization") != "" { + t.Fatalf("Expected no authentication header when not authenticated, got %v", h.Get("Authorization")) + } +} + +func TestClient_DoRetryAfter(t *testing.T) { + testStart := time.Now() + secondAttemptTime := time.Now() + + finish, c := newTestServerCallbacks(t, + func(tb testing.TB, w http.ResponseWriter, req *http.Request) string { + w.Header().Set("content-type", "application/json;charset=utf-8") + w.Header().Set("Retry-After", "1") + w.WriteHeader(http.StatusTooManyRequests) + return "" + }, + func(tb testing.TB, w http.ResponseWriter, req *http.Request) string { + w.Header().Set("content-type", "application/json;charset=utf-8") + w.WriteHeader(http.StatusOK) + secondAttemptTime = time.Now() + return "/HTTP_200.json" + }, + ) + defer finish() + + require.Nil(t, c.SendSimpleMetric("some_category", "some_action", "some_label")) + waitedTime := secondAttemptTime.Sub(testStart) + isInRange := 1*time.Second < waitedTime && waitedTime <= 11*time.Second + require.True(t, isInRange, "Waited time: %v", waitedTime) +} + +type slowTransport struct { + transport http.RoundTripper + firstBodySleep time.Duration +} + +func (t *slowTransport) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := t.transport.RoundTrip(req) + if err == nil { + resp.Body = &slowReadCloser{ + req: req, + readCloser: resp.Body, + firstBodySleep: t.firstBodySleep, + } + } + return resp, err +} + +type slowReadCloser struct { + req *http.Request + readCloser io.ReadCloser + firstBodySleep time.Duration +} + +func (r *slowReadCloser) Read(p []byte) (n int, err error) { + // Normally timeout is processed by Read function. + // It's hard to test slow connection; we need to manually + // check when context is Done, because otherwise timeout + // happens only during failed Read which will not happen + // in this artificial environment. + select { + case <-r.req.Context().Done(): + return 0, context.Canceled + case <-time.After(r.firstBodySleep): + } + return r.readCloser.Read(p) +} + +func (r *slowReadCloser) Close() error { + return r.readCloser.Close() +} + +func TestClient_FirstReadTimeout(t *testing.T) { + requestTimeout := testClientConfig.FirstReadTimeout + 1*time.Second + + finish, c := newTestServerCallbacks(t, + func(tb testing.TB, w http.ResponseWriter, req *http.Request) string { + return "/HTTP_200.json" + }, + ) + defer finish() + + c.client.Transport = &slowTransport{ + transport: c.client.Transport, + firstBodySleep: requestTimeout, + } + + started := time.Now() + err := c.SendSimpleMetric("some_category", "some_action", "some_label") + require.Error(t, err, "cannot reach the server") + require.True(t, time.Since(started) < requestTimeout, "Actual waited time: %v", time.Since(started)) +} + +func TestClient_MinSpeedTimeout(t *testing.T) { + finish, c := newTestServerCallbacks(t, + routeSlow(2*time.Second), + ) + defer finish() + + err := c.SendSimpleMetric("some_category", "some_action", "some_label") + require.Error(t, err, "cannot reach the server") +} + +func TestClient_MinSpeedNoTimeout(t *testing.T) { + finish, c := newTestServerCallbacks(t, + routeSlow(500*time.Millisecond), + ) + defer finish() + + err := c.SendSimpleMetric("some_category", "some_action", "some_label") + require.Nil(t, err) +} + +func routeSlow(delay time.Duration) func(tb testing.TB, w http.ResponseWriter, req *http.Request) string { + return func(tb testing.TB, w http.ResponseWriter, req *http.Request) string { + w.Header().Set("content-type", "application/json;charset=utf-8") + w.WriteHeader(http.StatusOK) + + _, _ = w.Write([]byte("{\"code\":1000,\"key\":\"")) + for chunk := 1; chunk <= 10; chunk++ { + // We need to write enough bytes which enforce flushing data + // because writer used by httptest does not implement Flusher. + for i := 1; i <= 10000; i++ { + _, _ = w.Write([]byte("a")) + } + time.Sleep(delay) + } + _, _ = w.Write([]byte("\"}")) + return "" + } +} diff --git a/pkg/pmapi/config.go b/pkg/pmapi/config.go new file mode 100644 index 00000000..37ee5811 --- /dev/null +++ b/pkg/pmapi/config.go @@ -0,0 +1,43 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "net/http" + "runtime" +) + +// RootURL is the API root URL. +// +// This can be changed using build flags: pmapi_local for "http://localhost/api", +// pmapi_dev or pmapi_prod. Default is pmapi_prod. +var RootURL = "https://api.protonmail.ch" //nolint[gochecknoglobals] + +// CurrentUserAgent is the default User-Agent for go-pmapi lib. This can be changed to program +// version and email client. +// e.g. Bridge/1.0.4 (Windows) MicrosoftOutlook/16.0.9330.2087 +var CurrentUserAgent = "GoPMAPI/1.0.14 (" + runtime.GOOS + "; no client)" //nolint[gochecknoglobals] + +// The HTTP transport to use by default. +var defaultTransport = &http.Transport{ //nolint[gochecknoglobals] + Proxy: http.ProxyFromEnvironment, +} + +// checkTLSCerts controls whether TLS certs are checked against known fingerprints. +// The default is for this to always be done. +var checkTLSCerts = true //nolint[gochecknoglobals] diff --git a/pkg/pmapi/config_dev.go b/pkg/pmapi/config_dev.go new file mode 100644 index 00000000..a456c4af --- /dev/null +++ b/pkg/pmapi/config_dev.go @@ -0,0 +1,24 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build pmapi_dev + +package pmapi + +func init() { + RootURL = "https://dev.protonmail.com/api" +} diff --git a/pkg/pmapi/config_local.go b/pkg/pmapi/config_local.go new file mode 100644 index 00000000..f534f250 --- /dev/null +++ b/pkg/pmapi/config_local.go @@ -0,0 +1,37 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build pmapi_local + +package pmapi + +import ( + "crypto/tls" + "net/http" +) + +func init() { + // Use port above 1000 which doesn't need root access to start anything on it. + // Now the port is rounded pi. :-) + RootURL = "http://127.0.0.1:3142/api" + + // TLS certificate is self-signed + defaultTransport = &http.Transport{ + Proxy: http.ProxyFromEnvironment, + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } +} diff --git a/pkg/pmapi/config_nopin.go b/pkg/pmapi/config_nopin.go new file mode 100644 index 00000000..cbb2e131 --- /dev/null +++ b/pkg/pmapi/config_nopin.go @@ -0,0 +1,25 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build pmapi_nopin + +package pmapi + +func init() { + // This config disables TLS cert checking. + checkTLSCerts = false +} diff --git a/pkg/pmapi/conrep.go b/pkg/pmapi/conrep.go new file mode 100644 index 00000000..c75a8ea6 --- /dev/null +++ b/pkg/pmapi/conrep.go @@ -0,0 +1,23 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +// ConnectionReporter provides a way to report when internet connection is lost. +type ConnectionReporter interface { + NotifyConnectionLost() error +} diff --git a/pkg/pmapi/contacts.go b/pkg/pmapi/contacts.go new file mode 100644 index 00000000..26959569 --- /dev/null +++ b/pkg/pmapi/contacts.go @@ -0,0 +1,430 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "errors" + "net/url" + "strconv" +) + +type Card struct { + Type int + Data string + Signature string +} + +const ( + CardEncrypted = 1 + CardSigned = 2 +) + +type Contact struct { + ID string + Name string + UID string + Size int64 + CreateTime int64 + ModifyTime int64 + LabelIDs []string + + ContactEmails []ContactEmail + Cards []Card +} + +type ContactEmail struct { + ID string + Name string + Email string + Type []string + Defaults int + Order int + ContactID string + LabelIDs []string +} + +var errVerificationFailed = errors.New("signature verification failed") + +//================= Public utility functions ====================== + +func (c *Client) EncryptAndSignCards(cards []Card) ([]Card, error) { + var err error + for i := range cards { + card := &cards[i] + if isEncryptedCardType(card.Type) { + if isSignedCardType(card.Type) { + if card.Signature, err = c.sign(card.Data); err != nil { + return nil, err + } + } + + if card.Data, err = c.encrypt(card.Data, nil); err != nil { + return nil, err + } + } else if isSignedCardType(card.Type) { + if card.Signature, err = c.sign(card.Data); err != nil { + return nil, err + } + } + } + return cards, nil +} + +func (c *Client) DecryptAndVerifyCards(cards []Card) ([]Card, error) { + for i := range cards { + card := &cards[i] + if isEncryptedCardType(card.Type) { + signedCard, err := c.decrypt(card.Data) + if err != nil { + return nil, err + } + card.Data = signedCard + } + if isSignedCardType(card.Type) { + err := c.verify(card.Data, card.Signature) + if err != nil { + return cards, errVerificationFailed + } + } + } + return cards, nil +} + +//====================== READ =========================== + +type ContactsListRes struct { + Res + Contacts []*Contact +} + +// GetContacts gets all contacts. +func (c *Client) GetContacts(page int, pageSize int) (contacts []*Contact, err error) { + v := url.Values{} + v.Set("Page", strconv.Itoa(page)) + if pageSize > 0 { + v.Set("PageSize", strconv.Itoa(pageSize)) + } + req, err := NewRequest("GET", "/contacts?"+v.Encode(), nil) + + if err != nil { + return + } + + var res ContactsListRes + if err = c.DoJSON(req, &res); err != nil { + return + } + + contacts, err = res.Contacts, res.Err() + return +} + +// GetContactByID gets contact details specified by contact ID. +func (c *Client) GetContactByID(id string) (contactDetail Contact, err error) { + req, err := NewRequest("GET", "/contacts/"+id, nil) + + if err != nil { + return + } + + type ContactRes struct { + Res + Contact Contact + } + var res ContactRes + + if err = c.DoJSON(req, &res); err != nil { + return + } + + contactDetail, err = res.Contact, res.Err() + return +} + +// GetContactsForExport gets contacts in vCard format, signed and encrypted. +func (c *Client) GetContactsForExport(page int, pageSize int) (contacts []Contact, err error) { + v := url.Values{} + v.Set("Page", strconv.Itoa(page)) + if pageSize > 0 { + v.Set("PageSize", strconv.Itoa(pageSize)) + } + + req, err := NewRequest("GET", "/contacts/export?"+v.Encode(), nil) + + if err != nil { + return + } + + type ContactsDetailsRes struct { + Res + Contacts []Contact + } + var res ContactsDetailsRes + + if err = c.DoJSON(req, &res); err != nil { + return + } + + contacts, err = res.Contacts, res.Err() + return +} + +type ContactsEmailsRes struct { + Res + ContactEmails []ContactEmail + Total int +} + +// GetAllContactsEmails gets all emails from all contacts. +func (c *Client) GetAllContactsEmails(page int, pageSize int) (contactsEmails []ContactEmail, err error) { + v := url.Values{} + v.Set("Page", strconv.Itoa(page)) + if pageSize > 0 { + v.Set("PageSize", strconv.Itoa(pageSize)) + } + + req, err := NewRequest("GET", "/contacts/emails?"+v.Encode(), nil) + if err != nil { + return + } + + var res ContactsEmailsRes + if err = c.DoJSON(req, &res); err != nil { + return + } + + contactsEmails, err = res.ContactEmails, res.Err() + return +} + +// GetContactEmailByEmail gets all emails from all contacts matching a specified email string. +func (c *Client) GetContactEmailByEmail(email string, page int, pageSize int) (contactEmails []ContactEmail, err error) { + v := url.Values{} + v.Set("Page", strconv.Itoa(page)) + if pageSize > 0 { + v.Set("PageSize", strconv.Itoa(pageSize)) + } + v.Set("Email", email) + + req, err := NewRequest("GET", "/contacts/emails?"+v.Encode(), nil) + if err != nil { + return + } + + var res ContactsEmailsRes + if err = c.DoJSON(req, &res); err != nil { + return + } + + contactEmails, err = res.ContactEmails, res.Err() + return +} + +//============================ CREATE ==================================== + +type CardsList struct { + Cards []Card +} + +type ContactsCards struct { + Contacts []CardsList +} + +type SingleContactResponse struct { + Res + Contact Contact +} + +type IndexedContactResponse struct { + Index int + Response SingleContactResponse +} + +type AddContactsResponse struct { + Res + Responses []IndexedContactResponse +} + +type AddContactsReq struct { + ContactsCards + Overwrite int + Groups int + Labels int +} + +// AddContacts adds contacts specified by cards. Performs signing and encrypting based on card type. +func (c *Client) AddContacts(cards ContactsCards, overwrite int, groups int, labels int) (res *AddContactsResponse, err error) { + reqBody := AddContactsReq{ + ContactsCards: cards, + Overwrite: overwrite, + Groups: groups, + Labels: labels, + } + + req, err := NewJSONRequest("POST", "/contacts", reqBody) + if err != nil { + return + } + + var addContactsRes AddContactsResponse + if err = c.DoJSON(req, &addContactsRes); err != nil { + return + } + + res, err = &addContactsRes, addContactsRes.Err() + return +} + +// ================================= UPDATE ======================================= + +type UpdateContactResponse struct { + Res + Contact Contact +} + +type UpdateContactReq struct { + Cards []Card +} + +// UpdateContact updates contact identified by contact ID. Modified contact is specified by cards. +func (c *Client) UpdateContact(id string, cards []Card) (res *UpdateContactResponse, err error) { + reqBody := UpdateContactReq{ + Cards: cards, + } + req, err := NewJSONRequest("PUT", "/contacts/"+id, reqBody) + if err != nil { + return + } + var updateContactRes UpdateContactResponse + if err = c.DoJSON(req, &updateContactRes); err != nil { + return + } + + res, err = &updateContactRes, updateContactRes.Err() + return +} + +type SingleIDResponse struct { + Res + ID string +} + +type UpdateContactGroupsResponse struct { + Res + Response SingleIDResponse +} + +func (c *Client) AddContactGroups(groupID string, contactEmailIDs []string) (res *UpdateContactGroupsResponse, err error) { + return c.modifyContactGroups(groupID, addContactGroupsAction, contactEmailIDs) +} + +func (c *Client) RemoveContactGroups(groupID string, contactEmailIDs []string) (res *UpdateContactGroupsResponse, err error) { + return c.modifyContactGroups(groupID, removeContactGroupsAction, contactEmailIDs) +} + +const ( + removeContactGroupsAction = 0 + addContactGroupsAction = 1 +) + +type ModifyContactGroupsReq struct { + LabelID string + Action int + ContactEmailIDs []string +} + +func (c *Client) modifyContactGroups(groupID string, modifyContactGroupsAction int, contactEmailIDs []string) (res *UpdateContactGroupsResponse, err error) { + reqBody := ModifyContactGroupsReq{ + LabelID: groupID, + Action: modifyContactGroupsAction, + ContactEmailIDs: contactEmailIDs, + } + req, err := NewJSONRequest("PUT", "/contacts/group", reqBody) + if err != nil { + return + } + if err = c.DoJSON(req, &res); err != nil { + return + } + err = res.Err() + return +} + +// ================================= DELETE ======================================= + +type DeleteReq struct { + IDs []string +} + +// DeleteContacts deletes contacts specified by an array of contact IDs. +func (c *Client) DeleteContacts(ids []string) (err error) { + deleteReq := DeleteReq{ + IDs: ids, + } + + req, err := NewJSONRequest("PUT", "/contacts/delete", deleteReq) + if err != nil { + return + } + + type DeleteContactsRes struct { + Res + Responses []struct { + ID string + Response Res + } + } + var res DeleteContactsRes + + if err = c.DoJSON(req, &res); err != nil { + return + } + if err = res.Err(); err != nil { + return + } + return +} + +// DeleteAllContacts deletes all contacts. +func (c *Client) DeleteAllContacts() (err error) { + req, err := NewRequest("DELETE", "/contacts", nil) + if err != nil { + return + } + + var res Res + + if err = c.DoJSON(req, &res); err != nil { + return + } + if err = res.Err(); err != nil { + return + } + + return +} + +//===================== Private utility methods ======================= + +func isSignedCardType(cardType int) bool { + return (cardType & CardSigned) == CardSigned +} + +func isEncryptedCardType(cardType int) bool { + return (cardType & CardEncrypted) == CardEncrypted +} diff --git a/pkg/pmapi/contacts_test.go b/pkg/pmapi/contacts_test.go new file mode 100644 index 00000000..1aae9367 --- /dev/null +++ b/pkg/pmapi/contacts_test.go @@ -0,0 +1,677 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "encoding/json" + "fmt" + "net/http" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" +) + +var ( + CleartextCard = 0 + EncryptedCard = 1 + SignedCard = 2 + EncryptedSignedCard = 3 +) + +var testAddContactsReq = AddContactsReq{ + ContactsCards: ContactsCards{ + Contacts: []CardsList{ + { + Cards: []Card{ + { + Type: 2, + Data: `BEGIN:VCARD +VERSION:4.0 +FN;TYPE=fn:Bob +item1.EMAIL:bob.tester@protonmail.com +UID:proton-web-cd974706-5cde-0e53-e131-c49c88a92ece +END:VCARD +`, + Signature: ``, + }, + }, + }, + }, + }, + Overwrite: 0, + Groups: 0, + Labels: 0, +} + +var testAddContactsResponseBody = `{ + "Code": 1001, + "Responses": [ + { + "Index": 0, + "Response": { + "Code": 1000, + "Contact": { + "ID": "EU7qYvPAdgJ-zl53hw_btO1WG8TN2FYh2cTIFq1_T6KqulwgxF8CzPjVk_RBUdEejtLvfynlelVNoZwMK_9X2g==", + "Name": "Bob", + "UID": "proton-web-cd974706-5cde-0e53-e131-c49c88a92ece", + "Size": 139, + "CreateTime": 1517319495, + "ModifyTime": 1517319495, + "ContactEmails": [ + { + "ID": "VT4NoPeQPk48_vg0CVmk63n5mB6CZn9q-P_DYODhOUemhuzUkgBFGF1MktVArjX5zsVdfVlEBFObvt0_K5NwPg==", + "Name": "Bob", + "Email": "bob.tester@protonmail.com", + "Type": [], + "Defaults": 1, + "Order": 1, + "ContactID": "EU7qYvPAdgJ-zl53hw_btO1WG8TN2FYh2cTIFq1_T6KqulwgxF8CzPjVk_RBUdEejtLvfynlelVNoZwMK_9X2g==", + "LabelIDs": [] + } + ], + "LabelIDs": [] + } + } + } + ] +}` + +var testContactCreated = &AddContactsResponse{ + Res: Res{ + Code: 1001, + StatusCode: 200, + }, + Responses: []IndexedContactResponse{ + { + Index: 0, + Response: SingleContactResponse{ + Res: Res{ + Code: 1000, + }, + Contact: Contact{ + ID: "EU7qYvPAdgJ-zl53hw_btO1WG8TN2FYh2cTIFq1_T6KqulwgxF8CzPjVk_RBUdEejtLvfynlelVNoZwMK_9X2g==", + Name: "Bob", + UID: "proton-web-cd974706-5cde-0e53-e131-c49c88a92ece", + Size: 139, + CreateTime: 1517319495, + ModifyTime: 1517319495, + ContactEmails: []ContactEmail{ + { + ID: "VT4NoPeQPk48_vg0CVmk63n5mB6CZn9q-P_DYODhOUemhuzUkgBFGF1MktVArjX5zsVdfVlEBFObvt0_K5NwPg==", + Name: "Bob", + Email: "bob.tester@protonmail.com", + Type: []string{}, + Defaults: 1, + Order: 1, + ContactID: "EU7qYvPAdgJ-zl53hw_btO1WG8TN2FYh2cTIFq1_T6KqulwgxF8CzPjVk_RBUdEejtLvfynlelVNoZwMK_9X2g==", + LabelIDs: []string{}, + }, + }, + LabelIDs: []string{}, + }, + }, + }, + }, +} + +var testContactUpdated = &UpdateContactResponse{ + Res: Res{ + Code: 1000, + StatusCode: 200, + }, + Contact: Contact{ + ID: "l4PrVkmDsIIDba9aln829uwPK0nnyWZHnFtrsyb7CJsYgrD6JTVTuuoaVmaANfO2jIVxzZ2vtbt74rznGjjwFQ==", + Name: "Bob", + UID: "proton-web-cd974706-5cde-0e53-e131-c49c88a92ece", + Size: 303, + CreateTime: 1517416603, + ModifyTime: 1517416656, + ContactEmails: []ContactEmail{ + { + ID: "14n6vuf1zbeo3zsYzgV471S6xJ9gzl7-VZ8tcOTQq6ifBlNEre0SUdUM7sXh6e2Q_4NhJZaU9c7jLdB1HCV6dA==", + Name: "Bob", + Email: "bob.changed.tester@protonmail.com", + Type: []string{}, + Defaults: 1, + Order: 1, + ContactID: "l4PrVkmDsIIDba9aln829uwPK0nnyWZHnFtrsyb7CJsYgrD6JTVTuuoaVmaANfO2jIVxzZ2vtbt74rznGjjwFQ==", + LabelIDs: []string{}, + }, + }, + LabelIDs: []string{}, + }, +} + +func TestContact_AddContact(t *testing.T) { + s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Ok(t, checkMethodAndPath(r, "POST", "/contacts")) + + var addContactsReq AddContactsReq + if err := json.NewDecoder(r.Body).Decode(&addContactsReq); err != nil { + t.Error("Expecting no error while reading request body, got:", err) + } + if !reflect.DeepEqual(testAddContactsReq.ContactsCards, addContactsReq.ContactsCards) { + t.Errorf("Invalid contacts request: expected %+v but got %+v", testAddContactsReq.ContactsCards, addContactsReq.ContactsCards) + } + + fmt.Fprint(w, testAddContactsResponseBody) + })) + defer s.Close() + + created, err := c.AddContacts(testAddContactsReq.ContactsCards, 0, 0, 0) + if err != nil { + t.Fatal("Expected no error while adding contact, got:", err) + } + + if !reflect.DeepEqual(created, testContactCreated) { + t.Fatalf("Invalid created contact: expected %+v, got %+v", testContactCreated, created) + } +} + +var testGetContactsResponseBody = `{ + "Code": 1000, + "Contacts": [ + { + "ID": "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==", + "Name": "Alice", + "UID": "proton-web-98c8de5e-4536-140b-9ab0-bd8ab6a2050b", + "Size": 243, + "CreateTime": 1517395498, + "ModifyTime": 1517395498, + "LabelIDs": [] + }, + { + "ID": "c6CWuyEE6mMRApAxvvCO9MQKydTU8Do1iikL__M5MoWWjDEebzChAUx-73qa1jTV54RzFO5p9pLBPsIIgCwpww==", + "Name": "Bob", + "UID": "proton-web-cd974706-5cde-0e53-e131-c49c88a92ece", + "Size": 303, + "CreateTime": 1517394677, + "ModifyTime": 1517394678, + "LabelIDs": [] + } + ], + "Total": 2 +}` + +var testGetContacts = []*Contact{ + + { + ID: "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==", + Name: "Alice", + UID: "proton-web-98c8de5e-4536-140b-9ab0-bd8ab6a2050b", + Size: 243, + CreateTime: 1517395498, + ModifyTime: 1517395498, + LabelIDs: []string{}, + }, + { + ID: "c6CWuyEE6mMRApAxvvCO9MQKydTU8Do1iikL__M5MoWWjDEebzChAUx-73qa1jTV54RzFO5p9pLBPsIIgCwpww==", + Name: "Bob", + UID: "proton-web-cd974706-5cde-0e53-e131-c49c88a92ece", + Size: 303, + CreateTime: 1517394677, + ModifyTime: 1517394678, + LabelIDs: []string{}, + }, +} + +func TestContact_GetContacts(t *testing.T) { + s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Ok(t, checkMethodAndPath(r, "GET", "/contacts?Page=0&PageSize=1000")) + + fmt.Fprint(w, testGetContactsResponseBody) + })) + defer s.Close() + + contacts, err := c.GetContacts(0, 1000) + if err != nil { + t.Fatal("Expected no error while getting contacts, got:", err) + } + + if !reflect.DeepEqual(contacts, testGetContacts) { + t.Fatalf("Invalid created contact: expected %+v, got %+v", testGetContacts, contacts) + } +} + +var testGetContactByIDResponseBody = `{ + "Code": 1000, + "Contact": { + "ID": "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==", + "Name": "Alice", + "UID": "proton-web-98c8de5e-4536-140b-9ab0-bd8ab6a2050b", + "Size": 243, + "CreateTime": 1517395498, + "ModifyTime": 1517395498, + "Cards": [ + { + "Type": 3, + "Data": "-----BEGIN PGP MESSAGE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwcBMA1vYAFKnBP8gAQf/RnOQRpo8DVJHQSJRgckEaUQvdMcADiM4L23diyiS\nQfclby/Ve2WInmvZc2RJ3rWENfeqyDZE6krQT642pKiW09GOIyVIjl+hje9y\nE4HBX0AIAWv7QhhKX6UZcM5dYSFbV3j3QxQB8A4Thng2G6ltotMTlbtcHbhu\n96Lt6ngA1tngXLSF5seyflnoiSQ5gLi2qVzrd95dIP6D4Ottcp929/4hDGmq\nPyxw9dColx6gVd1bmIDSI6ewkET4Grmo6QYqjSvjqLOf0PqHKzqypSFLkI5l\nmmnWKYTQCgl9wX+hq6Qz5E+m/BtbkdeX0YxYUss2e+oSAzJmnfdETErG9U5z\n3NJqAc3sgdwDzfWHBzogAxAbDHiqrF6zMlR5SFvZ6nRU7M2DTOE5dJhf+zOp\n1WSKn5LR46LGyt0m5wJPDjaGyQdPffAO4EULvwhGENe10UxRjY1qcUmjYOtS\nunl/vh3afI9PC1jj+HHJD2VgCA==\n=UpcY\n-----END PGP MESSAGE-----\n", + "Signature": "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZ4pCRDMO9BwcW4mpAAA6h0H/2+97koXzly5pu9hpbaW\n75d1Q976RjMr5DjAx6tKFtSzznel8YfWgvA6OQmMGdPY8ae7/+3mwCJZYWy/\nXVvUfCSflmYpSIKGfP+Vm1XezWY1W84DGhiFj5n8sdaWisv3bpFwFf1YR3Ae\noBoZ4ufNzaQALRqGPMgXETtXZCtzuL/+0vGSKj5SLECiRcSE4jCPEVRy2bcl\nWJyB9r4VmcjF042OMHxphXoYmTEWvgigyaQFHNORu5cK9EHfHpCG6IcjGbdx\n+9Px5YnDY1ix+YpBKePGSTlLE0u6ow0VTUrdvNjl7IUBaRcfJcIIdgCBOTMw\n1uQ/yeyP46V5AFXFnIKeZeQ=\n=FlOf\n-----END PGP SIGNATURE-----\n" + }, + { + "Type": 2, + "Data": "BEGIN:VCARD\nVERSION:4.0\nFN;TYPE=fn:Alice\nitem1.EMAIL:alice@protonmail.com\nUID:proton-web-98c8de5e-4536-140b-9ab0-bd8ab6a2050b\nEND:VCARD", + "Signature": "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZ4qCRDMO9BwcW4mpAAA3jUIAJ88mIyO8Yj0+evSFXnK\nNxNdjNgn7t1leY0BWlh1nkK76XrZEPipdw2QU8cOcZzn1Wby2SGfZVkwoPc4\nzAhPT4WKbkFVqXhDry5399kLwGYJCxdEcw/oPyYj+YgpQKMxhTrQq21tbEwr\n7JDRBXgi3Cckh/XsteFHOIiAVnM7BV6zFudipnYxa4uNF0Bf4VbUZx1Mm0Wb\nMJaGsO5reqQUQzDPO5TdSAZ8qGSdjVv7RESgUu5DckcDSsnB987Zbh9uFc22\nfPYmb6zA0cEZh3dAjpDPT7cg8hlvfYBb+kP3sLFyLiIkdEG8Pcagjf0k+l76\nr1IsPlYBx2LJmsJf+WDNlj8=\n=Xn+3\n-----END PGP SIGNATURE-----\n" + } + ], + "ContactEmails": [ + { + "ID": "4m2sBxLq4McqD0D330Kuy5xG-yyDNXyLEjG5_RYcjy9X-3qHGNP07DNOWLY40TYtUAQr4fAVp8zOcZ_z2o6H-A==", + "Name": "Alice", + "Email": "alice@protonmail.com", + "Type": [], + "Defaults": 1, + "Order": 1, + "ContactID": "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==", + "LabelIDs": [] + } + ], + "LabelIDs": [] + } +}` + +var testGetContactByID = Contact{ + ID: "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==", + Name: "Alice", + UID: "proton-web-98c8de5e-4536-140b-9ab0-bd8ab6a2050b", + Size: 243, + CreateTime: 1517395498, + ModifyTime: 1517395498, + Cards: []Card{ + { + Type: 3, + Data: "-----BEGIN PGP MESSAGE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwcBMA1vYAFKnBP8gAQf/RnOQRpo8DVJHQSJRgckEaUQvdMcADiM4L23diyiS\nQfclby/Ve2WInmvZc2RJ3rWENfeqyDZE6krQT642pKiW09GOIyVIjl+hje9y\nE4HBX0AIAWv7QhhKX6UZcM5dYSFbV3j3QxQB8A4Thng2G6ltotMTlbtcHbhu\n96Lt6ngA1tngXLSF5seyflnoiSQ5gLi2qVzrd95dIP6D4Ottcp929/4hDGmq\nPyxw9dColx6gVd1bmIDSI6ewkET4Grmo6QYqjSvjqLOf0PqHKzqypSFLkI5l\nmmnWKYTQCgl9wX+hq6Qz5E+m/BtbkdeX0YxYUss2e+oSAzJmnfdETErG9U5z\n3NJqAc3sgdwDzfWHBzogAxAbDHiqrF6zMlR5SFvZ6nRU7M2DTOE5dJhf+zOp\n1WSKn5LR46LGyt0m5wJPDjaGyQdPffAO4EULvwhGENe10UxRjY1qcUmjYOtS\nunl/vh3afI9PC1jj+HHJD2VgCA==\n=UpcY\n-----END PGP MESSAGE-----\n", + Signature: "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZ4pCRDMO9BwcW4mpAAA6h0H/2+97koXzly5pu9hpbaW\n75d1Q976RjMr5DjAx6tKFtSzznel8YfWgvA6OQmMGdPY8ae7/+3mwCJZYWy/\nXVvUfCSflmYpSIKGfP+Vm1XezWY1W84DGhiFj5n8sdaWisv3bpFwFf1YR3Ae\noBoZ4ufNzaQALRqGPMgXETtXZCtzuL/+0vGSKj5SLECiRcSE4jCPEVRy2bcl\nWJyB9r4VmcjF042OMHxphXoYmTEWvgigyaQFHNORu5cK9EHfHpCG6IcjGbdx\n+9Px5YnDY1ix+YpBKePGSTlLE0u6ow0VTUrdvNjl7IUBaRcfJcIIdgCBOTMw\n1uQ/yeyP46V5AFXFnIKeZeQ=\n=FlOf\n-----END PGP SIGNATURE-----\n", + }, + { + Type: 2, + Data: "BEGIN:VCARD\nVERSION:4.0\nFN;TYPE=fn:Alice\nitem1.EMAIL:alice@protonmail.com\nUID:proton-web-98c8de5e-4536-140b-9ab0-bd8ab6a2050b\nEND:VCARD", + Signature: "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZ4qCRDMO9BwcW4mpAAA3jUIAJ88mIyO8Yj0+evSFXnK\nNxNdjNgn7t1leY0BWlh1nkK76XrZEPipdw2QU8cOcZzn1Wby2SGfZVkwoPc4\nzAhPT4WKbkFVqXhDry5399kLwGYJCxdEcw/oPyYj+YgpQKMxhTrQq21tbEwr\n7JDRBXgi3Cckh/XsteFHOIiAVnM7BV6zFudipnYxa4uNF0Bf4VbUZx1Mm0Wb\nMJaGsO5reqQUQzDPO5TdSAZ8qGSdjVv7RESgUu5DckcDSsnB987Zbh9uFc22\nfPYmb6zA0cEZh3dAjpDPT7cg8hlvfYBb+kP3sLFyLiIkdEG8Pcagjf0k+l76\nr1IsPlYBx2LJmsJf+WDNlj8=\n=Xn+3\n-----END PGP SIGNATURE-----\n", + }, + }, + ContactEmails: []ContactEmail{ + { + ID: "4m2sBxLq4McqD0D330Kuy5xG-yyDNXyLEjG5_RYcjy9X-3qHGNP07DNOWLY40TYtUAQr4fAVp8zOcZ_z2o6H-A==", + Name: "Alice", + Email: "alice@protonmail.com", + Type: []string{}, + Defaults: 1, + Order: 1, + ContactID: "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==", + LabelIDs: []string{}, + }, + }, + LabelIDs: []string{}, +} + +func TestContact_GetContactById(t *testing.T) { + s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Ok(t, checkMethodAndPath(r, "GET", "/contacts/s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==")) + + fmt.Fprint(w, testGetContactByIDResponseBody) + })) + defer s.Close() + + contact, err := c.GetContactByID("s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==") + if err != nil { + t.Fatal("Expected no error while getting contacts, got:", err) + } + + if !reflect.DeepEqual(contact, testGetContactByID) { + t.Fatalf("Invalid got contact: expected %+v, got %+v", testGetContactByID, contact) + } +} + +var testGetContactsForExportResponseBody = `{ + "Code": 1000, + "Contacts": [ + { + "ID": "c6CWuyEE6mMRApAxvvCO9MQKydTU8Do1iikL__M5MoWWjDEebzChAUx-73qa1jTV54RzFO5p9pLBPsIIgCwpww==", + "Cards": [ + { + "Type": 2, + "Data": "BEGIN:VCARD\nVERSION:4.0\nFN;TYPE=fn:Bob\nitem1.EMAIL:bob.changed.tester@protonmail.com\nUID:proton-web-cd974706-5cde-0e53-e131-c49c88a92ece\nEND:VCARD\n", + "Signature": "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZr2CRDMO9BwcW4mpAAAtxwIAFGgPO+xH4PHppffQC1R\nxCp/Bjzaq5rDUE3ZMKVJ1sFqGVlq2bP5CIN4w2XCe/MuZ+z2o87fSEtt2n7i\n0/8Ah35u4czn7t8FZoW8u9WwHPURa8gUbP3fYpVASBY1Bt2fUxJrSUYn5KQp\njJM/DgF99bhIjOTuhx9IN7DFKG647Arq+GJh9M6RJNxkb3CBfcCVUXoIwMB7\nnM/fA1r+mcl8dQam0WKVJgy9aO2XUUR62w1SpqJlXY3z8hKvXjjskzU3DQk5\net07RLVQvhy2nCZePsM+TJzL8OBbTa1aF/p1xPe+HND7t3ZCm9tQOY+UhK5H\nbhPbQY48KGdci1dTcm2HbsQ=\n=iOnV\n-----END PGP SIGNATURE-----\n" + }, + { + "Type": 3, + "Data": "-----BEGIN PGP MESSAGE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwcBMA1vYAFKnBP8gAQgABsQWnqZadqrHDN43McGhEYfJjOB66R5HhkQAUavP\nHaAHpJciGxfz6tbztQu4C6kdMA80ElbD8c+bJqalw6ZbT4seoP4TTQLykD1n\n0LuNBlaW4x8kfd8rZzFdckk/dY2PruX6byAjSZslnZlZSwp99AJJbvJtfXRR\nzunKMbDieRkaApGZYT25wT5mz1embpXFesvO4nDkOEQCa0uyti3mNSLhYlf/\ntbaOS3WM9VYM9eB9YRZGzJNxMtTxOsd45tBlGCHnCzWEUnJdqZuYzH2QOky7\nMckXhk6YwyemYi/q7OOgSYEg/0lCs2EK3b//14yPDx8Bj5G7rZrnDgsP+BHj\nu9KaAZb2pSBPQoJ2DY3Y4A2Sg8GjaX5CMO9D6GKJkZSYkXddQgcmw7sVPUS+\n+5JaPXlfxoJOOn9kj9A6LDC6eMhYaLujG1BKcZ16DB0jqfwMnPLJ+bYEdatr\nKMvd9rbdsDwQ/tfk11VvHpiEBCNZjxM2+bdBLl9q2EXaLXi+dz/rJg5C0A9u\nNS2CzCUvg6+jNUzHo/RBfRXvlNV8tw==\n=mE2b\n-----END PGP MESSAGE-----\n", + "Signature": "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZr2CRDMO9BwcW4mpAAApucIAD/uwWuV6DOg127XIPG6\n/jluL8jmwyCJX9noL6S8ZVMOymziKSh4/P1QyMPC5SL4lMPEiuaEdyetfBkU\n+5hW3tcZ+ptxmDi59SVYqmXTVewPgeB7t8c5nbzCuVuzA7ZAo8HAXHzFVQDS\nj9fKVGjZzQkmlwdcfnkXHAF0Ejilv9wxOOYgqVDuzm7JXVF3Um7nAgGKTJE5\n5CNnrEjmJGapj96mQFwXzET/kAhNIBw9tL5FAkDlKImdw8C0w9sXdvDu3yVM\ntvUZ5o2rR6ft0SC1byFso49vgJ/syeK6P2pPzltZJbsp4MvmlPUB0/G1XRU+\nI7q4IOWCvs8RD88ADmOty2o=\n=hyZE\n-----END PGP SIGNATURE-----\n" + } + ] + }, + { + "ID": "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==", + "Cards": [ + { + "Type": 3, + "Data": "-----BEGIN PGP MESSAGE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwcBMA1vYAFKnBP8gAQf/RnOQRpo8DVJHQSJRgckEaUQvdMcADiM4L23diyiS\nQfclby/Ve2WInmvZc2RJ3rWENfeqyDZE6krQT642pKiW09GOIyVIjl+hje9y\nE4HBX0AIAWv7QhhKX6UZcM5dYSFbV3j3QxQB8A4Thng2G6ltotMTlbtcHbhu\n96Lt6ngA1tngXLSF5seyflnoiSQ5gLi2qVzrd95dIP6D4Ottcp929/4hDGmq\nPyxw9dColx6gVd1bmIDSI6ewkET4Grmo6QYqjSvjqLOf0PqHKzqypSFLkI5l\nmmnWKYTQCgl9wX+hq6Qz5E+m/BtbkdeX0YxYUss2e+oSAzJmnfdETErG9U5z\n3NJqAc3sgdwDzfWHBzogAxAbDHiqrF6zMlR5SFvZ6nRU7M2DTOE5dJhf+zOp\n1WSKn5LR46LGyt0m5wJPDjaGyQdPffAO4EULvwhGENe10UxRjY1qcUmjYOtS\nunl/vh3afI9PC1jj+HHJD2VgCA==\n=UpcY\n-----END PGP MESSAGE-----\n", + "Signature": "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZ4pCRDMO9BwcW4mpAAA6h0H/2+97koXzly5pu9hpbaW\n75d1Q976RjMr5DjAx6tKFtSzznel8YfWgvA6OQmMGdPY8ae7/+3mwCJZYWy/\nXVvUfCSflmYpSIKGfP+Vm1XezWY1W84DGhiFj5n8sdaWisv3bpFwFf1YR3Ae\noBoZ4ufNzaQALRqGPMgXETtXZCtzuL/+0vGSKj5SLECiRcSE4jCPEVRy2bcl\nWJyB9r4VmcjF042OMHxphXoYmTEWvgigyaQFHNORu5cK9EHfHpCG6IcjGbdx\n+9Px5YnDY1ix+YpBKePGSTlLE0u6ow0VTUrdvNjl7IUBaRcfJcIIdgCBOTMw\n1uQ/yeyP46V5AFXFnIKeZeQ=\n=FlOf\n-----END PGP SIGNATURE-----\n" + }, + { + "Type": 2, + "Data": "BEGIN:VCARD\nVERSION:4.0\nFN;TYPE=fn:Alice\nitem1.EMAIL:alice@protonmail.com\nUID:proton-web-98c8de5e-4536-140b-9ab0-bd8ab6a2050b\nEND:VCARD", + "Signature": "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZ4qCRDMO9BwcW4mpAAA3jUIAJ88mIyO8Yj0+evSFXnK\nNxNdjNgn7t1leY0BWlh1nkK76XrZEPipdw2QU8cOcZzn1Wby2SGfZVkwoPc4\nzAhPT4WKbkFVqXhDry5399kLwGYJCxdEcw/oPyYj+YgpQKMxhTrQq21tbEwr\n7JDRBXgi3Cckh/XsteFHOIiAVnM7BV6zFudipnYxa4uNF0Bf4VbUZx1Mm0Wb\nMJaGsO5reqQUQzDPO5TdSAZ8qGSdjVv7RESgUu5DckcDSsnB987Zbh9uFc22\nfPYmb6zA0cEZh3dAjpDPT7cg8hlvfYBb+kP3sLFyLiIkdEG8Pcagjf0k+l76\nr1IsPlYBx2LJmsJf+WDNlj8=\n=Xn+3\n-----END PGP SIGNATURE-----\n" + } + ] + } + ], + "Total": 2 +}` + +var testGetContactsForExport = []Contact{ + { + ID: "c6CWuyEE6mMRApAxvvCO9MQKydTU8Do1iikL__M5MoWWjDEebzChAUx-73qa1jTV54RzFO5p9pLBPsIIgCwpww==", + Cards: []Card{ + { + Type: 2, + Data: "BEGIN:VCARD\nVERSION:4.0\nFN;TYPE=fn:Bob\nitem1.EMAIL:bob.changed.tester@protonmail.com\nUID:proton-web-cd974706-5cde-0e53-e131-c49c88a92ece\nEND:VCARD\n", + Signature: "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZr2CRDMO9BwcW4mpAAAtxwIAFGgPO+xH4PHppffQC1R\nxCp/Bjzaq5rDUE3ZMKVJ1sFqGVlq2bP5CIN4w2XCe/MuZ+z2o87fSEtt2n7i\n0/8Ah35u4czn7t8FZoW8u9WwHPURa8gUbP3fYpVASBY1Bt2fUxJrSUYn5KQp\njJM/DgF99bhIjOTuhx9IN7DFKG647Arq+GJh9M6RJNxkb3CBfcCVUXoIwMB7\nnM/fA1r+mcl8dQam0WKVJgy9aO2XUUR62w1SpqJlXY3z8hKvXjjskzU3DQk5\net07RLVQvhy2nCZePsM+TJzL8OBbTa1aF/p1xPe+HND7t3ZCm9tQOY+UhK5H\nbhPbQY48KGdci1dTcm2HbsQ=\n=iOnV\n-----END PGP SIGNATURE-----\n", + }, + { + Type: 3, + Data: "-----BEGIN PGP MESSAGE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwcBMA1vYAFKnBP8gAQgABsQWnqZadqrHDN43McGhEYfJjOB66R5HhkQAUavP\nHaAHpJciGxfz6tbztQu4C6kdMA80ElbD8c+bJqalw6ZbT4seoP4TTQLykD1n\n0LuNBlaW4x8kfd8rZzFdckk/dY2PruX6byAjSZslnZlZSwp99AJJbvJtfXRR\nzunKMbDieRkaApGZYT25wT5mz1embpXFesvO4nDkOEQCa0uyti3mNSLhYlf/\ntbaOS3WM9VYM9eB9YRZGzJNxMtTxOsd45tBlGCHnCzWEUnJdqZuYzH2QOky7\nMckXhk6YwyemYi/q7OOgSYEg/0lCs2EK3b//14yPDx8Bj5G7rZrnDgsP+BHj\nu9KaAZb2pSBPQoJ2DY3Y4A2Sg8GjaX5CMO9D6GKJkZSYkXddQgcmw7sVPUS+\n+5JaPXlfxoJOOn9kj9A6LDC6eMhYaLujG1BKcZ16DB0jqfwMnPLJ+bYEdatr\nKMvd9rbdsDwQ/tfk11VvHpiEBCNZjxM2+bdBLl9q2EXaLXi+dz/rJg5C0A9u\nNS2CzCUvg6+jNUzHo/RBfRXvlNV8tw==\n=mE2b\n-----END PGP MESSAGE-----\n", + Signature: "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZr2CRDMO9BwcW4mpAAApucIAD/uwWuV6DOg127XIPG6\n/jluL8jmwyCJX9noL6S8ZVMOymziKSh4/P1QyMPC5SL4lMPEiuaEdyetfBkU\n+5hW3tcZ+ptxmDi59SVYqmXTVewPgeB7t8c5nbzCuVuzA7ZAo8HAXHzFVQDS\nj9fKVGjZzQkmlwdcfnkXHAF0Ejilv9wxOOYgqVDuzm7JXVF3Um7nAgGKTJE5\n5CNnrEjmJGapj96mQFwXzET/kAhNIBw9tL5FAkDlKImdw8C0w9sXdvDu3yVM\ntvUZ5o2rR6ft0SC1byFso49vgJ/syeK6P2pPzltZJbsp4MvmlPUB0/G1XRU+\nI7q4IOWCvs8RD88ADmOty2o=\n=hyZE\n-----END PGP SIGNATURE-----\n", + }, + }, + }, + { + ID: "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==", + Cards: []Card{ + { + Type: 3, + Data: "-----BEGIN PGP MESSAGE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwcBMA1vYAFKnBP8gAQf/RnOQRpo8DVJHQSJRgckEaUQvdMcADiM4L23diyiS\nQfclby/Ve2WInmvZc2RJ3rWENfeqyDZE6krQT642pKiW09GOIyVIjl+hje9y\nE4HBX0AIAWv7QhhKX6UZcM5dYSFbV3j3QxQB8A4Thng2G6ltotMTlbtcHbhu\n96Lt6ngA1tngXLSF5seyflnoiSQ5gLi2qVzrd95dIP6D4Ottcp929/4hDGmq\nPyxw9dColx6gVd1bmIDSI6ewkET4Grmo6QYqjSvjqLOf0PqHKzqypSFLkI5l\nmmnWKYTQCgl9wX+hq6Qz5E+m/BtbkdeX0YxYUss2e+oSAzJmnfdETErG9U5z\n3NJqAc3sgdwDzfWHBzogAxAbDHiqrF6zMlR5SFvZ6nRU7M2DTOE5dJhf+zOp\n1WSKn5LR46LGyt0m5wJPDjaGyQdPffAO4EULvwhGENe10UxRjY1qcUmjYOtS\nunl/vh3afI9PC1jj+HHJD2VgCA==\n=UpcY\n-----END PGP MESSAGE-----\n", + Signature: "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZ4pCRDMO9BwcW4mpAAA6h0H/2+97koXzly5pu9hpbaW\n75d1Q976RjMr5DjAx6tKFtSzznel8YfWgvA6OQmMGdPY8ae7/+3mwCJZYWy/\nXVvUfCSflmYpSIKGfP+Vm1XezWY1W84DGhiFj5n8sdaWisv3bpFwFf1YR3Ae\noBoZ4ufNzaQALRqGPMgXETtXZCtzuL/+0vGSKj5SLECiRcSE4jCPEVRy2bcl\nWJyB9r4VmcjF042OMHxphXoYmTEWvgigyaQFHNORu5cK9EHfHpCG6IcjGbdx\n+9Px5YnDY1ix+YpBKePGSTlLE0u6ow0VTUrdvNjl7IUBaRcfJcIIdgCBOTMw\n1uQ/yeyP46V5AFXFnIKeZeQ=\n=FlOf\n-----END PGP SIGNATURE-----\n", + }, + { + Type: 2, + Data: "BEGIN:VCARD\nVERSION:4.0\nFN;TYPE=fn:Alice\nitem1.EMAIL:alice@protonmail.com\nUID:proton-web-98c8de5e-4536-140b-9ab0-bd8ab6a2050b\nEND:VCARD", + Signature: "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZ4qCRDMO9BwcW4mpAAA3jUIAJ88mIyO8Yj0+evSFXnK\nNxNdjNgn7t1leY0BWlh1nkK76XrZEPipdw2QU8cOcZzn1Wby2SGfZVkwoPc4\nzAhPT4WKbkFVqXhDry5399kLwGYJCxdEcw/oPyYj+YgpQKMxhTrQq21tbEwr\n7JDRBXgi3Cckh/XsteFHOIiAVnM7BV6zFudipnYxa4uNF0Bf4VbUZx1Mm0Wb\nMJaGsO5reqQUQzDPO5TdSAZ8qGSdjVv7RESgUu5DckcDSsnB987Zbh9uFc22\nfPYmb6zA0cEZh3dAjpDPT7cg8hlvfYBb+kP3sLFyLiIkdEG8Pcagjf0k+l76\nr1IsPlYBx2LJmsJf+WDNlj8=\n=Xn+3\n-----END PGP SIGNATURE-----\n", + }, + }, + }, +} + +func TestContact_GetContactsForExport(t *testing.T) { + s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Ok(t, checkMethodAndPath(r, "GET", "/contacts/export?Page=0&PageSize=1000")) + + fmt.Fprint(w, testGetContactsForExportResponseBody) + })) + defer s.Close() + + contacts, err := c.GetContactsForExport(0, 1000) + if err != nil { + t.Fatal("Expected no error while getting contacts for export, got:", err) + } + + if !reflect.DeepEqual(contacts, testGetContactsForExport) { + t.Fatalf("Invalid contact for export: expected %+v, got %+v", testGetContactsForExport, contacts) + } +} + +var testGetContactsEmailsResponseBody = `{ + "Code": 1000, + "ContactEmails": [ + { + "ID": "Hgyz1tG0OiC2v_hMIVOa6juMOAp_recWNzWII7a79Tfwdx08Jy3FJY0_Y_UtFYwbi6mN-Xx1sOI9_GmUGAcwWg==", + "Name": "Bob", + "Email": "bob.changed.tester@protonmail.com", + "Type": [], + "Defaults": 1, + "Order": 1, + "ContactID": "c6CWuyEE6mMRApAxvvCO9MQKydTU8Do1iikL__M5MoWWjDEebzChAUx-73qa1jTV54RzFO5p9pLBPsIIgCwpww==", + "LabelIDs": [] + }, + { + "ID": "4m2sBxLq4McqD0D330Kuy5xG-yyDNXyLEjG5_RYcjy9X-3qHGNP07DNOWLY40TYtUAQr4fAVp8zOcZ_z2o6H-A==", + "Name": "Alice", + "Email": "alice@protonmail.com", + "Type": [], + "Defaults": 1, + "Order": 1, + "ContactID": "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==", + "LabelIDs": [] + } + ], + "Total": 2 +}` + +var testGetContactsEmails = []ContactEmail{ + { + ID: "Hgyz1tG0OiC2v_hMIVOa6juMOAp_recWNzWII7a79Tfwdx08Jy3FJY0_Y_UtFYwbi6mN-Xx1sOI9_GmUGAcwWg==", + Name: "Bob", + Email: "bob.changed.tester@protonmail.com", + Type: []string{}, + Defaults: 1, + Order: 1, + ContactID: "c6CWuyEE6mMRApAxvvCO9MQKydTU8Do1iikL__M5MoWWjDEebzChAUx-73qa1jTV54RzFO5p9pLBPsIIgCwpww==", + LabelIDs: []string{}, + }, + { + ID: "4m2sBxLq4McqD0D330Kuy5xG-yyDNXyLEjG5_RYcjy9X-3qHGNP07DNOWLY40TYtUAQr4fAVp8zOcZ_z2o6H-A==", + Name: "Alice", + Email: "alice@protonmail.com", + Type: []string{}, + Defaults: 1, + Order: 1, + ContactID: "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==", + LabelIDs: []string{}, + }, +} + +func TestContact_GetAllContactsEmails(t *testing.T) { + s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Ok(t, checkMethodAndPath(r, "GET", "/contacts/emails?Page=0&PageSize=1000")) + + fmt.Fprint(w, testGetContactsEmailsResponseBody) + })) + defer s.Close() + + contactsEmails, err := c.GetAllContactsEmails(0, 1000) + if err != nil { + t.Fatal("Expected no error while getting contacts for export, got:", err) + } + + if !reflect.DeepEqual(contactsEmails, testGetContactsEmails) { + t.Fatalf("Invalid contact for export: expected %+v, got %+v", testGetContactsEmails, contactsEmails) + } +} + +var testUpdateContactReq = UpdateContactReq{ + Cards: []Card{ + { + Type: 2, + Data: `BEGIN:VCARD +VERSION:4.0 +FN;TYPE=fn:Bob +item1.EMAIL:bob.changed.tester@protonmail.com +UID:proton-web-cd974706-5cde-0e53-e131-c49c88a92ece +END:VCARD +`, + Signature: ``, + }, + }, +} + +var testUpdateContactResponseBody = `{ + "Code": 1000, + "Contact": { + "ID": "l4PrVkmDsIIDba9aln829uwPK0nnyWZHnFtrsyb7CJsYgrD6JTVTuuoaVmaANfO2jIVxzZ2vtbt74rznGjjwFQ==", + "Name": "Bob", + "UID": "proton-web-cd974706-5cde-0e53-e131-c49c88a92ece", + "Size": 303, + "CreateTime": 1517416603, + "ModifyTime": 1517416656, + "ContactEmails": [ + { + "ID": "14n6vuf1zbeo3zsYzgV471S6xJ9gzl7-VZ8tcOTQq6ifBlNEre0SUdUM7sXh6e2Q_4NhJZaU9c7jLdB1HCV6dA==", + "Name": "Bob", + "Email": "bob.changed.tester@protonmail.com", + "Type": [], + "Defaults": 1, + "Order": 1, + "ContactID": "l4PrVkmDsIIDba9aln829uwPK0nnyWZHnFtrsyb7CJsYgrD6JTVTuuoaVmaANfO2jIVxzZ2vtbt74rznGjjwFQ==", + "LabelIDs": [] + } + ], + "LabelIDs": [] + } +}` + +func TestContact_UpdateContact(t *testing.T) { + s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Ok(t, checkMethodAndPath(r, "PUT", "/contacts/l4PrVkmDsIIDba9aln829uwPK0nnyWZHnFtrsyb7CJsYgrD6JTVTuuoaVmaANfO2jIVxzZ2vtbt74rznGjjwFQ==")) + + var updateContactReq UpdateContactReq + if err := json.NewDecoder(r.Body).Decode(&updateContactReq); err != nil { + t.Error("Expecting no error while reading request body, got:", err) + } + if !reflect.DeepEqual(testUpdateContactReq.Cards, updateContactReq.Cards) { + t.Errorf("Invalid contacts request: expected %+v but got %+v", testUpdateContactReq.Cards, updateContactReq.Cards) + } + + fmt.Fprint(w, testUpdateContactResponseBody) + })) + defer s.Close() + + created, err := c.UpdateContact("l4PrVkmDsIIDba9aln829uwPK0nnyWZHnFtrsyb7CJsYgrD6JTVTuuoaVmaANfO2jIVxzZ2vtbt74rznGjjwFQ==", testUpdateContactReq.Cards) + if err != nil { + t.Fatal("Expected no error while updating contact, got:", err) + } + + if !reflect.DeepEqual(created, testContactUpdated) { + t.Fatalf("Invalid updated contact: expected\n%+v\ngot\n%+v\n", testContactUpdated, created) + } +} + +var testDeleteContactsReq = DeleteReq{ + IDs: []string{ + "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==", + }, +} + +var testDeleteContactsResponseBody = `{ + "Code": 1001, + "Responses": [ + { + "ID": "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==", + "Response": { + "Code": 1000 + } + } + ] +}` + +func TestContact_DeleteContacts(t *testing.T) { + s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Ok(t, checkMethodAndPath(r, "PUT", "/contacts/delete")) + + var deleteContactsReq DeleteReq + if err := json.NewDecoder(r.Body).Decode(&deleteContactsReq); err != nil { + t.Error("Expecting no error while reading request body, got:", err) + } + if !reflect.DeepEqual(testDeleteContactsReq.IDs, deleteContactsReq.IDs) { + t.Errorf("Invalid delete contacts request: expected %+v but got %+v", deleteContactsReq.IDs, testDeleteContactsReq.IDs) + } + + fmt.Fprint(w, testDeleteContactsResponseBody) + })) + defer s.Close() + + err := c.DeleteContacts([]string{"s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg=="}) + if err != nil { + t.Fatal("Expected no error while getting contacts for export, got:", err) + } +} + +var testDeleteAllResponseBody = `{ + "Code": 1000 +}` + +func TestContact_DeleteAllContacts(t *testing.T) { + s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Ok(t, checkMethodAndPath(r, "DELETE", "/contacts")) + + fmt.Fprint(w, testDeleteAllResponseBody) + })) + defer s.Close() + + err := c.DeleteAllContacts() + if err != nil { + t.Fatal("Expected no error while getting contacts for export, got:", err) + } +} + +func TestContact_isSignedCardType(t *testing.T) { + if !isSignedCardType(SignedCard) || !isSignedCardType(EncryptedSignedCard) { + t.Fatal("isSignedCardType shouldn't return false for signed card types") + } + if isSignedCardType(CleartextCard) || isSignedCardType(EncryptedCard) { + t.Fatal("isSignedCardType shouldn't return true for non-signed card types") + } +} + +func TestContact_isEncryptedCardType(t *testing.T) { + if !isEncryptedCardType(EncryptedCard) || !isEncryptedCardType(EncryptedSignedCard) { + t.Fatal("isEncryptedCardType shouldn't return false for encrypted card types") + } + if isEncryptedCardType(CleartextCard) || isEncryptedCardType(SignedCard) { + t.Fatal("isEncryptedCardType shouldn't return true for non-encrypted card types") + } +} + +var testCardsEncrypted = []Card{ + { + Type: EncryptedSignedCard, + Data: "-----BEGIN PGP MESSAGE-----\nVersion: GopenPGP 0.0.1 (ddacebe0)\nComment: https://gopenpgp.org\n\nwcBMA0fcZ7XLgmf2AQf/fLKA6ZCkDxumpDoUoFQfO86B9LFuqGEJq+voP12C6UXo\nfB2nTy/K4+VosLKYOkU9sW1PZOCL+i00z+zkqUZ6jchbZBpzwy/UCTmpPRw5zrmr\nW6bZCwwgqJSGVWrvcrDA3bW9cn/HHqQqU6jNeXIF+IuhTscRAJVGehJZYWjr1lgB\nToJhg4+//Bgp/Fxzz8Fej/fsokgOlRJ8xcZKYx0rKL/+Il0u2jnd08kJTegpaY+6\nBlsYBzfYq25WkS02iy02wHbt6XD7AxFDi4WDjsM8bryLSm/KNWrejqfDYb/tMAKa\nKNJqK39/EUewzp1gHEXiGmdDEIFTKCHTDTPV84mwf9I1Ae4yoLs+ilYE6sSk7DCh\nPSWjDC8lpKzmw93slsejTG93HJKQPcZ0rLBpv6qPZX6widNYjDE=\n=QFxr\n-----END PGP MESSAGE-----", + Signature: "-----BEGIN PGP SIGNATURE-----\nVersion: GopenPGP 0.0.1 (ddacebe0)\nComment: https://gopenpgp.org\n\nwsBcBAABCgAQBQJdZQ1kCRA+tiWe3yHfJAAA9nMH/0X7pS8TGt6Ox0BewRh0vjfQ\n9LPLwbOiHdj97LNqutZcLlDTfm9SPH82221ZpVILWhB0u2kFeNUGihVbjAqJGYJn\nEk2TELLwn8csYRy9r5JkyUirqrvh7jgl4vs1yt8O/3Yb4ARudOoZr8Yrb4+NVNe0\nCcwQJnH/fJPtF1hbarKwtKtCo3IFwTis4pc8qWJRpBH61z1mO0Yr/LIh85QndhnF\nnZ/3MkWOY0kp2gl4ptqtNUw7z+JJ4LLVdT3ycdVK7GVTZmIG90y5KKxwJvrwbS7/\n8rmPGPQ5diLEMrzuKC2plXT6Pdy0ShtZxie2C3JY86e7ol7xvl0pNqxzOrj424w=\n=AOTG\n-----END PGP SIGNATURE-----", + }, +} + +var testCardsCleartext = []Card{ + { + Type: EncryptedSignedCard, + Data: "data", + Signature: "-----BEGIN PGP SIGNATURE-----\nVersion: GopenPGP 0.0.1 (ddacebe0)\nComment: https://gopenpgp.org\n\nwsBcBAABCgAQBQJdZQ1kCRA+tiWe3yHfJAAA9nMH/0X7pS8TGt6Ox0BewRh0vjfQ\n9LPLwbOiHdj97LNqutZcLlDTfm9SPH82221ZpVILWhB0u2kFeNUGihVbjAqJGYJn\nEk2TELLwn8csYRy9r5JkyUirqrvh7jgl4vs1yt8O/3Yb4ARudOoZr8Yrb4+NVNe0\nCcwQJnH/fJPtF1hbarKwtKtCo3IFwTis4pc8qWJRpBH61z1mO0Yr/LIh85QndhnF\nnZ/3MkWOY0kp2gl4ptqtNUw7z+JJ4LLVdT3ycdVK7GVTZmIG90y5KKxwJvrwbS7/\n8rmPGPQ5diLEMrzuKC2plXT6Pdy0ShtZxie2C3JY86e7ol7xvl0pNqxzOrj424w=\n=AOTG\n-----END PGP SIGNATURE-----", + }, +} + +func TestClient_Encrypt(t *testing.T) { + c := newTestClient() + c.kr = testPrivateKeyRing + + cardEncrypted, err := c.EncryptAndSignCards(testCardsCleartext) + assert.Nil(t, err) + + // Result is always different, so the best way is to test it by decrypting again. + // Another test for decrypting will help us to be sure it's working. + cardCleartext, err := c.DecryptAndVerifyCards(cardEncrypted) + assert.Nil(t, err) + assert.Equal(t, testCardsCleartext[0].Data, cardCleartext[0].Data) +} + +func TestClient_Decrypt(t *testing.T) { + c := newTestClient() + c.kr = testPrivateKeyRing + + cardCleartext, err := c.DecryptAndVerifyCards(testCardsEncrypted) + assert.Nil(t, err) + assert.Equal(t, testCardsCleartext[0].Data, cardCleartext[0].Data) +} diff --git a/pkg/pmapi/conversations.go b/pkg/pmapi/conversations.go new file mode 100644 index 00000000..9401016b --- /dev/null +++ b/pkg/pmapi/conversations.go @@ -0,0 +1,51 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +// ConversationsCount have same structure as MessagesCount. +type ConversationsCount MessagesCount + +// ConversationsCountsRes holds response from server. +type ConversationsCountsRes struct { + Res + + Counts []*ConversationsCount +} + +// Conversation contains one body and multiple metadata. +type Conversation struct{} + +// CountConversations counts conversations by label. +func (c *Client) CountConversations(addressID string) (counts []*ConversationsCount, err error) { + reqURL := "/conversations/count" + if addressID != "" { + reqURL += ("?AddressID=" + addressID) + } + req, err := NewRequest("GET", reqURL, nil) + if err != nil { + return + } + + var res ConversationsCountsRes + if err = c.DoJSON(req, &res); err != nil { + return + } + + counts, err = res.Counts, res.Err() + return +} diff --git a/pkg/pmapi/dialer_with_proxy.go b/pkg/pmapi/dialer_with_proxy.go new file mode 100644 index 00000000..f2db0234 --- /dev/null +++ b/pkg/pmapi/dialer_with_proxy.go @@ -0,0 +1,373 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "bytes" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "io/ioutil" + "net" + "net/http" + "strconv" + "time" + + "github.com/sirupsen/logrus" +) + +// TLSReport is inspired by https://tools.ietf.org/html/rfc7469#section-3. +type TLSReport struct { + // DateTime of observed pin validation in time.RFC3339 format. + DateTime string `json:"date-time"` + + // Hostname to which the UA made original request that failed pin validation. + Hostname string `json:"hostname"` + + // Port to which the UA made original request that failed pin validation. + Port int `json:"port"` + + // EffectiveExpirationDate for noted pins in time.RFC3339 format. + EffectiveExpirationDate string `json:"effective-expiration-date"` + + // IncludeSubdomains indicates whether or not the UA has noted the + // includeSubDomains directive for the Known Pinned Host. + IncludeSubdomains bool `json:"include-subdomains"` + + // NotedHostname indicates the hostname that the UA noted when it noted + // the Known Pinned Host. This field allows operators to understand why + // Pin Validation was performed for, e.g., foo.example.com when the + // noted Known Pinned Host was example.com with includeSubDomains set. + NotedHostname string `json:"noted-hostname"` + + // ServedCertificateChain is the certificate chain, as served by + // the Known Pinned Host during TLS session setup. It is provided as an + // array of strings; each string pem1, ... pemN is the Privacy-Enhanced + // Mail (PEM) representation of each X.509 certificate as described in + // [RFC7468]. + ServedCertificateChain []string `json:"served-certificate-chain"` + + // ValidatedCertificateChain is the certificate chain, as + // constructed by the UA during certificate chain verification. (This + // may differ from the served-certificate-chain.) It is provided as an + // array of strings; each string pem1, ... pemN is the PEM + // representation of each X.509 certificate as described in [RFC7468]. + // UAs that build certificate chains in more than one way during the + // validation process SHOULD send the last chain built. In this way, + // they can avoid keeping too much state during the validation process. + ValidatedCertificateChain []string `json:"validated-certificate-chain"` + + // The known-pins are the Pins that the UA has noted for the Known + // Pinned Host. They are provided as an array of strings with the + // syntax: known-pin = token "=" quoted-string + // e.g.: + // ``` + // "known-pins": [ + // 'pin-sha256="d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM="', + // "pin-sha256=\"E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=\"" + // ] + // ``` + KnownPins []string `json:"known-pins"` + + // AppVersion is used to set `x-pm-appversion` json format from datatheorem/TrustKit. + AppVersion string `json:"app-version"` +} + +// ErrTLSMatch indicates that no TLS fingerprint match could be found. +var ErrTLSMatch = fmt.Errorf("TLS fingerprint match not found") + +// DialerWithPinning will provide dial function which checks the fingerprints of public cert +// received from contacted server. If no match found among know pinse it will report using +// ReportCertIssueLocal. +type DialerWithPinning struct { + // isReported will stop reporting if true. + isReported bool + + // report stores known pins. + report TLSReport + + // When reportURI is not empty the tls issue report will be send to this URI. + reportURI string + + // ReportCertIssueLocal is used send signal to application about certificate issue. + // It is used only if set. + ReportCertIssueLocal func() + + // proxyManager manages API proxies. + proxyManager *proxyManager + + // A logger for logging messages. + log logrus.FieldLogger +} + +func NewDialerWithPinning(reportURI string, report TLSReport) *DialerWithPinning { + log := logrus.WithField("pkg", "pmapi/tls-pinning") + + proxyManager := newProxyManager(dohProviders, proxyQuery) + + return &DialerWithPinning{ + isReported: false, + reportURI: reportURI, + report: report, + proxyManager: proxyManager, + log: log, + } +} + +func NewPMAPIPinning(appVersion string) *DialerWithPinning { + return NewDialerWithPinning( + "https://reports.protonmail.ch/reports/tls", + TLSReport{ + EffectiveExpirationDate: time.Now().Add(365 * 24 * 60 * 60 * time.Second).Format(time.RFC3339), + IncludeSubdomains: false, + ValidatedCertificateChain: []string{}, + ServedCertificateChain: []string{}, + AppVersion: appVersion, + + // NOTE: the proxy pins are the same for all proxy servers, guaranteed by infra team ;) + KnownPins: []string{ + `pin-sha256="drtmcR2kFkM8qJClsuWgUzxgBkePfRCkRpqUesyDmeE="`, // current + `pin-sha256="YRGlaY0jyJ4Jw2/4M8FIftwbDIQfh8Sdro96CeEel54="`, // hot + `pin-sha256="AfMENBVvOS8MnISprtvyPsjKlPooqh8nMB/pvCrpJpw="`, // cold + `pin-sha256="EU6TS9MO0L/GsDHvVc9D5fChYLNy5JdGYpJw0ccgetM="`, // proxy main + `pin-sha256="iKPIHPnDNqdkvOnTClQ8zQAIKG0XavaPkcEo0LBAABA="`, // proxy backup 1 + `pin-sha256="MSlVrBCdL0hKyczvgYVSRNm88RicyY04Q2y5qrBt0xA="`, // proxy backup 2 + `pin-sha256="C2UxW0T1Ckl9s+8cXfjXxlEqwAfPM4HiW2y3UdtBeCw="`, // proxy backup 3 + }, + }, + ) +} + +func (p *DialerWithPinning) reportCertIssue(connState tls.ConnectionState) { + p.isReported = true + + if p.ReportCertIssueLocal != nil { + go p.ReportCertIssueLocal() + } + + if p.reportURI != "" { + p.report.NotedHostname = connState.ServerName + p.report.ServedCertificateChain = marshalCert7468(connState.PeerCertificates) + + if len(connState.VerifiedChains) > 0 { + p.report.ServedCertificateChain = marshalCert7468( + connState.VerifiedChains[len(connState.VerifiedChains)-1], + ) + } + + go p.reportCertIssueRemote() + } +} + +func (p *DialerWithPinning) reportCertIssueRemote() { + b, err := json.Marshal(p.report) + if err != nil { + p.log.Errorf("marshal request: %v", err) + return + } + + req, err := http.NewRequest("POST", p.reportURI, bytes.NewReader(b)) + if err != nil { + p.log.Errorf("create request: %v", err) + } + + req.Header.Add("Content-Type", "application/json") + req.Header.Set("User-Agent", CurrentUserAgent) + req.Header.Set("x-pm-apiversion", strconv.Itoa(Version)) + req.Header.Set("x-pm-appversion", p.report.AppVersion) + + p.log.Debugf("report req: %+v\n", req) + + c := &http.Client{} + res, err := c.Do(req) + p.log.Debugf("res: %+v\nerr: %v", res, err) + if err != nil { + return + } + _, _ = ioutil.ReadAll(res.Body) + if res.StatusCode != http.StatusOK { + p.log.Errorf("response status: %v", res.Status) + } + _ = res.Body.Close() +} + +func certFingerprint(cert *x509.Certificate) string { + hash := sha256.Sum256(cert.RawSubjectPublicKeyInfo) + return fmt.Sprintf(`pin-sha256=%q`, base64.StdEncoding.EncodeToString(hash[:])) +} + +func marshalCert7468(certs []*x509.Certificate) (pemCerts []string) { + var buffer bytes.Buffer + for _, cert := range certs { + if err := pem.Encode(&buffer, &pem.Block{ + Type: "CERTIFICATE", + Bytes: cert.Raw, + }); err != nil { + logrus.WithField("pkg", "pmapi/tls-pinning").Errorf("encoding TLS cert: %v", err) + } + pemCerts = append(pemCerts, buffer.String()) + buffer.Reset() + } + + return pemCerts +} + +func (p *DialerWithPinning) TransportWithPinning() *http.Transport { + return &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialTLS: p.dialAndCheckFingerprints, + MaxIdleConns: 100, + IdleConnTimeout: 5 * time.Minute, + ExpectContinueTimeout: 500 * time.Millisecond, + + // GODT-126: this was initially 10s but logs from users showed a significant number + // were hitting this timeout, possibly due to flaky wifi taking >10s to reconnect. + // Bumping to 30s for now to avoid this problem. + ResponseHeaderTimeout: 30 * time.Second, + + // If we allow up to 30 seconds for response headers, it is reasonable to allow up + // to 30 seconds for the TLS handshake to take place. + TLSHandshakeTimeout: 30 * time.Second, + } +} + +// dialAndCheckFingerprint to set as http.Transport.DialTLS. +// +// * note that when DialTLS is not nil the Transport.TLSClientConfig and Transport.TLSHandshakeTimeout are ignored. +// * dialAndCheckFingerprints fails if certificate is not valid (not signed by authority or not matching hostname). +// * dialAndCheckFingerprints will pass if certificate pin does not have a match, but will send notification using +// p.ReportCertIssueLocal() and p.reportCertIssueRemote() if they are not nil. +func (p *DialerWithPinning) dialAndCheckFingerprints(network, address string) (conn net.Conn, err error) { + // If DoH is enabled, we hardfail on fingerprint mismatches. + if globalIsDoHAllowed() && p.isReported { + return nil, ErrTLSMatch + } + + // Try to dial the given address but use a proxy if necessary. + if conn, err = p.dialWithProxyFallback(network, address); err != nil { + return + } + + // If cert issue was already reported, we don't want to check fingerprints anymore. + if p.isReported { + return nil, ErrTLSMatch + } + + // Check the cert fingerprint to ensure it is known. + if err = p.checkFingerprints(conn); err != nil { + p.log.WithError(err).Error("Error checking cert fingerprints") + return + } + + return +} + +// dialWithProxyFallback tries to dial the given address but falls back to alternative proxies if need be. +func (p *DialerWithPinning) dialWithProxyFallback(network, address string) (conn net.Conn, err error) { + var host, port string + if host, port, err = net.SplitHostPort(address); err != nil { + return + } + + // Try to dial, and if it succeeds, then just return. + if conn, err = p.dial(network, address); err == nil { + return + } + + // If DoH is not allowed, give up. Or, if we are dialing something other than the API + // (e.g. we dial protonmail.com/... to check for updates), there's also no point in + // continuing since a proxy won't help us reach that. + if !globalIsDoHAllowed() || host != stripProtocol(GlobalGetRootURL()) { + return + } + + // Find a new proxy. + var proxy string + if proxy, err = p.proxyManager.findProxy(); err != nil { + return + } + + // Switch to the proxy. + p.log.WithField("proxy", proxy).Debug("Switching to proxy") + p.proxyManager.useProxy(proxy) + + // Retry dial with proxy. + return p.dial(network, net.JoinHostPort(proxy, port)) +} + +// dial returns a connection to the given address using the given network. +func (p *DialerWithPinning) dial(network, address string) (conn net.Conn, err error) { + var port string + if p.report.Hostname, port, err = net.SplitHostPort(address); err != nil { + return + } + if p.report.Port, err = strconv.Atoi(port); err != nil { + return + } + p.report.DateTime = time.Now().Format(time.RFC3339) + + dialer := &net.Dialer{Timeout: 10 * time.Second} + + // If we are not dialing the standard API then we should skip cert verification checks. + var tlsConfig *tls.Config = nil + if address != stripProtocol(globalOriginalURL) { + tlsConfig = &tls.Config{InsecureSkipVerify: true} // nolint[gosec] + } + + return tls.DialWithDialer(dialer, network, address, tlsConfig) +} + +func (p *DialerWithPinning) checkFingerprints(conn net.Conn) (err error) { + if !checkTLSCerts { + return + } + + connState := conn.(*tls.Conn).ConnectionState() + + hasFingerprintMatch := false + for _, peerCert := range connState.PeerCertificates { + fingerprint := certFingerprint(peerCert) + + for i, pin := range p.report.KnownPins { + if pin == fingerprint { + hasFingerprintMatch = true + + if i != 0 { + p.log.Warnf("Matched fingerprint (%q) was not primary pinned key (was key #%d)", fingerprint, i) + } + + break + } + } + + if hasFingerprintMatch { + break + } + } + + if !hasFingerprintMatch { + p.reportCertIssue(connState) + return ErrTLSMatch + } + + return err +} diff --git a/pkg/pmapi/dialer_with_proxy_test.go b/pkg/pmapi/dialer_with_proxy_test.go new file mode 100644 index 00000000..dbdb74f1 --- /dev/null +++ b/pkg/pmapi/dialer_with_proxy_test.go @@ -0,0 +1,126 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +const liveAPI = "https://api.protonmail.ch" + +var testLiveConfig = &ClientConfig{ + AppVersion: "Bridge_1.2.4-test", + ClientID: "Bridge", +} + +func newTestDialerWithPinning() (*int, *DialerWithPinning) { + called := 0 + p := NewPMAPIPinning(testLiveConfig.AppVersion) + p.ReportCertIssueLocal = func() { called++ } + testLiveConfig.Transport = p.TransportWithPinning() + return &called, p +} + +func TestTLSPinValid(t *testing.T) { + called, _ := newTestDialerWithPinning() + + RootURL = liveAPI + client := NewClient(testLiveConfig, "pmapi"+t.Name()) + + _, err := client.AuthInfo("this.address.is.disabled") + Ok(t, err) + + Equals(t, 0, *called) +} + +func TestTLSPinBackup(t *testing.T) { + called, p := newTestDialerWithPinning() + p.report.KnownPins[1] = p.report.KnownPins[0] + p.report.KnownPins[0] = "" + + RootURL = liveAPI + client := NewClient(testLiveConfig, "pmapi"+t.Name()) + + _, err := client.AuthInfo("this.address.is.disabled") + Ok(t, err) + + Equals(t, 0, *called) +} + +func _TestTLSPinNoMatch(t *testing.T) { // nolint[unused] + called, p := newTestDialerWithPinning() + for i := 0; i < len(p.report.KnownPins); i++ { + p.report.KnownPins[i] = "testing" + } + + RootURL = liveAPI + client := NewClient(testLiveConfig, "pmapi"+t.Name()) + + _, err := client.AuthInfo("this.address.is.disabled") + Ok(t, err) + + // check that it will be called only once per session + client = NewClient(testLiveConfig, "pmapi"+t.Name()) + _, err = client.AuthInfo("this.address.is.disabled") + Ok(t, err) + + Equals(t, 1, *called) +} + +func _TestTLSPinInvalid(t *testing.T) { // nolint[unused] + ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSONResponsefromFile(t, w, "/auth/info/post_response.json", 0) + })) + defer ts.Close() + + called, _ := newTestDialerWithPinning() + + client := NewClient(testLiveConfig, "pmapi"+t.Name()) + + RootURL = liveAPI + _, err := client.AuthInfo("this.address.is.disabled") + Ok(t, err) + + RootURL = ts.URL + _, err = client.AuthInfo("this.address.is.disabled") + Assert(t, err != nil, "error is expected but have %v", err) + + Equals(t, 1, *called) +} + +func _TestTLSSignedCertWrongPublicKey(t *testing.T) { // nolint[unused] + _, dialer := newTestDialerWithPinning() + _, err := dialer.dialAndCheckFingerprints("tcp", "rsa4096.badssl.com:443") + Assert(t, err != nil, "expected dial to fail because of wrong public key: ", err.Error()) +} + +func _TestTLSSignedCertTrustedPublicKey(t *testing.T) { // nolint[unused] + _, dialer := newTestDialerWithPinning() + dialer.report.KnownPins = append(dialer.report.KnownPins, `pin-sha256="W8/42Z0ffufwnHIOSndT+eVzBJSC0E8uTIC8O6mEliQ="`) + _, err := dialer.dialAndCheckFingerprints("tcp", "rsa4096.badssl.com:443") + Assert(t, err == nil, "expected dial to succeed because public key is known and cert is signed by CA: ", err.Error()) +} + +func _TestTLSSelfSignedCertTrustedPublicKey(t *testing.T) { // nolint[unused] + _, dialer := newTestDialerWithPinning() + dialer.report.KnownPins = append(dialer.report.KnownPins, `pin-sha256="9SLklscvzMYj8f+52lp5ze/hY0CFHyLSPQzSpYYIBm8="`) + _, err := dialer.dialAndCheckFingerprints("tcp", "self-signed.badssl.com:443") + Assert(t, err == nil, "expected dial to succeed because public key is known despite cert being self-signed: ", err.Error()) +} diff --git a/pkg/pmapi/events.go b/pkg/pmapi/events.go new file mode 100644 index 00000000..98dd9989 --- /dev/null +++ b/pkg/pmapi/events.go @@ -0,0 +1,237 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "encoding/json" + "net/http" + "net/mail" +) + +// Event represents changes since the last check. +type Event struct { + // The current event ID. + EventID string + // If set to one, all cached data must be fetched again. + Refresh int + // If set to one, fetch more events. + More int + // Changes applied to messages. + Messages []*EventMessage + // Counts of messages per labels. + MessageCounts []*MessagesCount + // Changes applied to labels. + Labels []*EventLabel + // Current user status. + User User + // Changes to addresses. + Addresses []*EventAddress + // Messages to show to the user. + Notices []string +} + +// EventAction is the action that created a change. +type EventAction int + +const ( + EventDelete EventAction = iota // Item has been deleted. + EventCreate // Item has been created. + EventUpdate // Item has been updated. + EventUpdateFlags // For messages: flags have been updated. +) + +// Flags for event refresh. +const ( + EventRefreshMail = 1 + EventRefreshContact = 2 + EventRefreshAll = 255 +) + +// maxNumberOfMergedEvents limits how many events are merged into one. It means +// when GetEvent is called and event returns there is more events, it will +// automatically fetch next one and merge it up to this number of events. +const maxNumberOfMergedEvents = 50 + +// EventItem is an item that has changed. +type EventItem struct { + ID string + Action EventAction +} + +// EventMessage is a message that has changed. +type EventMessage struct { + EventItem + + // If the message has been created, the new message. + Created *Message `json:"-"` + // If the message has been updated, the updated fields. + Updated *EventMessageUpdated `json:"-"` +} + +// eventMessage defines a new type to prevent MarshalJSON/UnmarshalJSON infinite loops. +type eventMessage EventMessage + +type rawEventMessage struct { + eventMessage + + // This will be parsed depending on the action. + Message json.RawMessage `json:",omitempty"` +} + +func (em *EventMessage) UnmarshalJSON(b []byte) (err error) { + var raw rawEventMessage + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + *em = EventMessage(raw.eventMessage) + + switch em.Action { + case EventCreate: + em.Created = &Message{ID: raw.ID} + return json.Unmarshal(raw.Message, em.Created) + case EventUpdate, EventUpdateFlags: + em.Updated = &EventMessageUpdated{ID: raw.ID} + return json.Unmarshal(raw.Message, em.Updated) + } + return nil +} + +func (em *EventMessage) MarshalJSON() ([]byte, error) { + var raw rawEventMessage + raw.eventMessage = eventMessage(*em) + + var err error + switch em.Action { + case EventCreate: + raw.Message, err = json.Marshal(em.Created) + case EventUpdate, EventUpdateFlags: + raw.Message, err = json.Marshal(em.Updated) + } + if err != nil { + return nil, err + } + + return json.Marshal(raw) +} + +// EventMessageUpdated contains changed fields for an updated message. +type EventMessageUpdated struct { + ID string + + Subject *string + Unread *int + Flags *int64 + Sender *mail.Address + ToList *[]*mail.Address + CCList *[]*mail.Address + BCCList *[]*mail.Address + Time int64 + + // Fields only present for EventUpdateFlags. + LabelIDs []string + LabelIDsAdded []string + LabelIDsRemoved []string +} + +// EventLabel is a label that has changed. +type EventLabel struct { + EventItem + Label *Label +} + +// EventAddress is an address that has changed. +type EventAddress struct { + EventItem + Address *Address +} + +type EventRes struct { + Res + *Event +} + +type LatestEventRes struct { + Res + *Event +} + +// GetEvent returns a summary of events that occurred since last. To get the latest event, +// provide an empty last value. The latest event is always empty. +func (c *Client) GetEvent(last string) (event *Event, err error) { + return c.getEvent(last, 1) +} + +func (c *Client) getEvent(last string, numberOfMergedEvents int) (event *Event, err error) { + var req *http.Request + if last == "" { + req, err = NewRequest("GET", "/events/latest", nil) + if err != nil { + return + } + + var res LatestEventRes + if err = c.DoJSON(req, &res); err != nil { + return + } + + event, err = res.Event, res.Err() + } else { + req, err = NewRequest("GET", "/events/"+last, nil) + if err != nil { + return + } + + var res EventRes + if err = c.DoJSON(req, &res); err != nil { + return + } + + event, err = res.Event, res.Err() + if err != nil { + return + } + + if event.More == 1 && numberOfMergedEvents < maxNumberOfMergedEvents { + var moreEvents *Event + if moreEvents, err = c.getEvent(event.EventID, numberOfMergedEvents+1); err != nil { + return + } + event = mergeEvents(event, moreEvents) + } + } + + return event, err +} + +// mergeEvents combines an old events and a new events object. +// This is not as simple as just blindly joining the two because some things should only be taken from the new events. +func mergeEvents(eventsOld *Event, eventsNew *Event) (mergedEvents *Event) { + mergedEvents = &Event{ + EventID: eventsNew.EventID, + Refresh: eventsOld.Refresh | eventsNew.Refresh, + More: eventsNew.More, + Messages: append(eventsOld.Messages, eventsNew.Messages...), + MessageCounts: append(eventsOld.MessageCounts, eventsNew.MessageCounts...), + Labels: append(eventsOld.Labels, eventsNew.Labels...), + User: eventsNew.User, + Addresses: append(eventsOld.Addresses, eventsNew.Addresses...), + Notices: append(eventsOld.Notices, eventsNew.Notices...), + } + + return +} diff --git a/pkg/pmapi/events_test.go b/pkg/pmapi/events_test.go new file mode 100644 index 00000000..2ee47421 --- /dev/null +++ b/pkg/pmapi/events_test.go @@ -0,0 +1,524 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "fmt" + "net/http" + "regexp" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClient_GetEvent(t *testing.T) { + s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.NoError(t, checkMethodAndPath(r, "GET", "/events/latest")) + fmt.Fprint(w, testEventBody) + })) + defer s.Close() + + event, err := c.GetEvent("") + require.NoError(t, err) + require.Equal(t, testEvent, event) +} + +func TestClient_GetEvent_withID(t *testing.T) { + s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.NoError(t, checkMethodAndPath(r, "GET", "/events/"+testEvent.EventID)) + fmt.Fprint(w, testEventBody) + })) + defer s.Close() + + event, err := c.GetEvent(testEvent.EventID) + require.NoError(t, err) + require.Equal(t, testEvent, event) +} + +// We first call GetEvent with id of eventID1, which returns More=1 so we fetch with id eventID2. +func TestClient_GetEvent_mergeEvents(t *testing.T) { + s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.RequestURI() { + case "/events/eventID1": + assert.NoError(t, checkMethodAndPath(r, "GET", "/events/eventID1")) + fmt.Fprint(w, testEventBodyMore1) + case "/events/eventID2": + assert.NoError(t, checkMethodAndPath(r, "GET", "/events/eventID2")) + fmt.Fprint(w, testEventBodyMore2) + default: + t.Fail() + } + })) + defer s.Close() + + event, err := c.GetEvent("eventID1") + require.NoError(t, err) + require.Equal(t, testEventMerged, event) +} + +func TestClient_GetEvent_mergeMaxNumberOfEvents(t *testing.T) { + numberOfCalls := 0 + + s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + numberOfCalls++ + + re := regexp.MustCompile(`/eventID([0-9]+)`) + eventIDString := re.FindStringSubmatch(r.URL.RequestURI())[1] + eventID, err := strconv.Atoi(eventIDString) + require.NoError(t, err) + + if numberOfCalls > maxNumberOfMergedEvents*2 { + require.Fail(t, "Too many calls!") + } + + fmt.Println("") + + body := strings.ReplaceAll(testEventBodyMore1, "eventID2", "eventID"+strconv.Itoa(eventID+1)) + fmt.Fprint(w, body) + })) + defer s.Close() + + event, err := c.GetEvent("eventID1") + require.NoError(t, err) + require.Equal(t, maxNumberOfMergedEvents, numberOfCalls) + require.Equal(t, 1, event.More) +} + +var ( + testEventMessageUpdateUnread = 0 + + testEvent = &Event{ + EventID: "eventID1", + Refresh: 0, + Messages: []*EventMessage{ + { + EventItem: EventItem{ID: "hdI7aIgUO1hFplCIcJHB0jShRVsAzS0AB75wGCaiNVeIHXLmaUnt4eJ8l7c7L6uk4g0ZdXhGWG5gfh6HHgAZnw==", Action: EventCreate}, + Created: &Message{ + ID: "hdI7aIgUO1hFplCIcJHB0jShRVsAzS0AB75wGCaiNVeIHXLmaUnt4eJ8l7c7L6uk4g0ZdXhGWG5gfh6HHgAZnw==", + Subject: "Hey there", + }, + }, + { + EventItem: EventItem{ID: "bSFLAimPSfGz2Kj0aV3l3AyXsof_Vf7sfrrMJ8ifgGJe-f2NG2eLaEGXLytjMhq9wnLMtkoZpO2uBXM4nOVa5g==", Action: EventUpdateFlags}, + Updated: &EventMessageUpdated{ + ID: "bSFLAimPSfGz2Kj0aV3l3AyXsof_Vf7sfrrMJ8ifgGJe-f2NG2eLaEGXLytjMhq9wnLMtkoZpO2uBXM4nOVa5g==", + Unread: &testEventMessageUpdateUnread, + Time: 1472391377, + LabelIDsAdded: []string{ArchiveLabel}, + LabelIDsRemoved: []string{InboxLabel}, + }, + }, + { + EventItem: EventItem{ID: "XRBMBYnSkaEJWtqFACp2kjlNc-7GjzX3SnPcOtWK4PyLG11Nhsg0uxPYjTXoClQfB-EHVDl9gE3w2PVuj93jBg==", Action: EventDelete}, + }, + }, + MessageCounts: []*MessagesCount{ + { + LabelID: "0", + Total: 19, + Unread: 2, + }, + { + LabelID: "6", + Total: 1, + Unread: 0, + }, + }, + Notices: []string{"Server will be down in 2min because of a NSA attack"}, + } + + testEventMerged = &Event{ + EventID: "eventID3", + Refresh: 1, + Messages: []*EventMessage{ + { + EventItem: EventItem{ID: "msgID1", Action: EventCreate}, + Created: &Message{ + ID: "id", + Subject: "Hey there", + }, + }, + { + EventItem: EventItem{ID: "msgID2", Action: EventCreate}, + Created: &Message{ + ID: "id", + Subject: "Hey there again", + }, + }, + }, + MessageCounts: []*MessagesCount{ + { + LabelID: "label1", + Total: 19, + Unread: 2, + }, + { + LabelID: "label2", + Total: 1, + Unread: 0, + }, + { + LabelID: "label2", + Total: 2, + Unread: 1, + }, + { + LabelID: "label3", + Total: 1, + Unread: 0, + }, + }, + Notices: []string{"Server will be down in 2min because of a NSA attack", "Just kidding lol"}, + Labels: []*EventLabel{ + { + EventItem: EventItem{ + ID: "labelID1", + Action: 1, + }, + Label: &Label{ + ID: "id", + Name: "Event Label 1", + }, + }, + { + EventItem: EventItem{ + ID: "labelID2", + Action: 1, + }, + Label: &Label{ + ID: "id", + Name: "Event Label 2", + }, + }, + }, + User: User{ + ID: "userID1", + Name: "user", + UsedSpace: 23456, + }, + Addresses: []*EventAddress{ + { + EventItem: EventItem{ + ID: "addressID1", + Action: 2, + }, + Address: &Address{ + ID: "id", + DisplayName: "address 1", + }, + }, + { + EventItem: EventItem{ + ID: "addressID2", + Action: 2, + }, + Address: &Address{ + ID: "id", + DisplayName: "address 2", + }, + }, + }, + } +) + +const ( + testEventBody = `{ + "EventID": "eventID1", + "Refresh": 0, + "Messages": [ + { + "ID": "hdI7aIgUO1hFplCIcJHB0jShRVsAzS0AB75wGCaiNVeIHXLmaUnt4eJ8l7c7L6uk4g0ZdXhGWG5gfh6HHgAZnw==", + "Action": 1, + "Message": { + "ID": "hdI7aIgUO1hFplCIcJHB0jShRVsAzS0AB75wGCaiNVeIHXLmaUnt4eJ8l7c7L6uk4g0ZdXhGWG5gfh6HHgAZnw==", + "Subject": "Hey there" + } + }, + { + "ID": "bSFLAimPSfGz2Kj0aV3l3AyXsof_Vf7sfrrMJ8ifgGJe-f2NG2eLaEGXLytjMhq9wnLMtkoZpO2uBXM4nOVa5g==", + "Action": 3, + "Message": { + "ConversationID": "2oX3EILYRuZ0IRBVlzMg1oV5eazQL67sFIHlcR8bjickPn7K4id4sJZuAB6n0pdtI3hRIVsjCpgWfRm8c_x3IQ==", + "Unread": 0, + "Time": 1472391377, + "Location": 6, + "LabelIDsAdded": [ + "6" + ], + "LabelIDsRemoved": [ + "0" + ] + } + }, + { + "ID": "XRBMBYnSkaEJWtqFACp2kjlNc-7GjzX3SnPcOtWK4PyLG11Nhsg0uxPYjTXoClQfB-EHVDl9gE3w2PVuj93jBg==", + "Action": 0 + } + ], + "Conversations": [ + { + "ID": "2oX3EILYRuZ0IRBVlzMg1oV5eazQL67sFIHlcR8bjickPn7K4id4sJZuAB6n0pdtI3hRIVsjCpgWfRm8c_x3IQ==", + "Action": 1, + "Conversation": { + "ID": "2oX3EILYRuZ0IRBVlzMg1oV5eazQL67sFIHlcR8bjickPn7K4id4sJZuAB6n0pdtI3hRIVsjCpgWfRm8c_x3IQ==", + "Order": 1616, + "Subject": "Hey there", + "Senders": [ + { + "Address": "apple@protonmail.com", + "Name": "apple@protonmail.com" + } + ], + "Recipients": [ + { + "Address": "apple@protonmail.com", + "Name": "apple@protonmail.com" + } + ], + "NumMessages": 1, + "NumUnread": 1, + "NumAttachments": 0, + "ExpirationTime": 0, + "TotalSize": 636, + "AddressID": "QMJs2dzTx7uqpH5PNgIzjULywU4gO9uMBhEMVFOAVJOoUml54gC0CCHtW9qYwzH-zYbZwMv3MFYncPjW1Usq7Q==", + "LabelIDs": [ + "0" + ], + "Labels": [ + { + "Count": 1, + "NumMessages": 1, + "NumUnread": 1, + "ID": "0" + } + ] + } + } + ], + "Total": { + "Locations": [ + { + "Location": 0, + "Count": 19 + }, + { + "Location": 1, + "Count": 16 + }, + { + "Location": 2, + "Count": 16 + }, + { + "Location": 3, + "Count": 17 + }, + { + "Location": 6, + "Count": 1 + } + ], + "Labels": [ + { + "LabelID": "LLz8ysmVxwr4dF6mWpClePT0SpSWOEvzTdq17RydSl4ndMckvY1K63HeXDzn03BJQwKYvgf-eWT8Qfd9WVuIEQ==", + "Count": 2 + }, + { + "LabelID": "BvbqbySUPo9uWW_eR8tLA13NUsQMz3P4Zhw4UnpvrKqURnrHlE6L2Au0nplHfHlVXFgGz4L4hJ9-BYllOL-L5g==", + "Count": 2 + } + ], + "Starred": 3 + }, + "Unread": { + "Locations": [ + { + "Location": 0, + "Count": 2 + }, + { + "Location": 1, + "Count": 0 + }, + { + "Location": 2, + "Count": 0 + }, + { + "Location": 3, + "Count": 0 + }, + { + "Location": 6, + "Count": 0 + } + ], + "Labels": [ + { + "LabelID": "LLz8ysmVxwr4dF6mWpClePT0SpSWOEvzTdq17RydSl4ndMckvY1K63HeXDzn03BJQwKYvgf-eWT8Qfd9WVuIEQ==", + "Count": 0 + }, + { + "LabelID": "BvbqbySUPo9uWW_eR8tLA13NUsQMz3P4Zhw4UnpvrKqURnrHlE6L2Au0nplHfHlVXFgGz4L4hJ9-BYllOL-L5g==", + "Count": 0 + } + ], + "Starred": 0 + }, + "MessageCounts": [ + { + "LabelID": "0", + "Total": 19, + "Unread": 2 + }, + { + "LabelID": "6", + "Total": 1, + "Unread": 0 + } + ], + "ConversationCounts": [ + { + "LabelID": "0", + "Total": 19, + "Unread": 2 + }, + { + "LabelID": "6", + "Total": 1, + "Unread": 0 + } + ], + "UsedSpace": 7552905, + "Notices": ["Server will be down in 2min because of a NSA attack"], + "Code": 1000 +} +` + + testEventBodyMore1 = `{ + "EventID": "eventID2", + "More": 1, + "Refresh": 1, + "Messages": [ + { + "ID": "msgID1", + "Action": 1, + "Message": { + "ID": "id", + "Subject": "Hey there" + } + } + ], + "MessageCounts": [ + { + "LabelID": "label1", + "Total": 19, + "Unread": 2 + }, + { + "LabelID": "label2", + "Total": 1, + "Unread": 0 + } + ], + "Labels": [ + { + "ID":"labelID1", + "Action":1, + "Label":{ + "ID":"id", + "Name":"Event Label 1" + } + } + ], + "User": { + "ID": "userID1", + "Name": "user", + "UsedSpace": 12345 + }, + "Addresses": [ + { + "ID": "addressID1", + "Action": 2, + "Address": { + "ID": "id", + "DisplayName": "address 1" + } + } + ], + "Notices": ["Server will be down in 2min because of a NSA attack"] +} +` + + testEventBodyMore2 = `{ + "EventID": "eventID3", + "Refresh": 0, + "Messages": [ + { + "ID": "msgID2", + "Action": 1, + "Message": { + "ID": "id", + "Subject": "Hey there again" + } + } + ], + "MessageCounts": [ + { + "LabelID": "label2", + "Total": 2, + "Unread": 1 + }, + { + "LabelID": "label3", + "Total": 1, + "Unread": 0 + } + ], + "Labels": [ + { + "ID":"labelID2", + "Action":1, + "Label":{ + "ID":"id", + "Name":"Event Label 2" + } + } + ], + "User": { + "ID": "userID1", + "Name": "user", + "UsedSpace": 23456 + }, + "Addresses": [ + { + "ID": "addressID2", + "Action": 2, + "Address": { + "ID": "id", + "DisplayName": "address 2" + } + } + ], + "Notices": ["Just kidding lol"] +} +` +) diff --git a/pkg/pmapi/import.go b/pkg/pmapi/import.go new file mode 100644 index 00000000..83f25ee7 --- /dev/null +++ b/pkg/pmapi/import.go @@ -0,0 +1,157 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "encoding/json" + "io" + "mime/multipart" + "strconv" +) + +// Import errors. +const ( + ImportMessageTooLarge = 36022 +) + +// ImportReq is an import request. +type ImportReq struct { + // A list of messages that will be imported. + Messages []*ImportMsgReq +} + +// WriteTo writes the import request to a multipart writer. +func (req *ImportReq) WriteTo(w *multipart.Writer) (err error) { + // Create Metadata field. + mw, err := w.CreateFormField("Metadata") + if err != nil { + return + } + + // Build metadata. + metadata := map[string]*ImportMsgReq{} + for i, msg := range req.Messages { + name := strconv.Itoa(i) + metadata[name] = msg + } + + // Write metadata. + if err = json.NewEncoder(mw).Encode(metadata); err != nil { + return + } + + // Write messages. + for i, msg := range req.Messages { + name := strconv.Itoa(i) + + var fw io.Writer + if fw, err = w.CreateFormFile(name, name+".eml"); err != nil { + return err + } + + if _, err = fw.Write(msg.Body); err != nil { + return + } + } + + return err +} + +// ImportMsgReq is a request to import a message. All fields are optional except AddressID and Body. +type ImportMsgReq struct { + // The address where the message will be imported. + AddressID string + // The full MIME message. + Body []byte `json:"-"` + + // 0: read, 1: unread. + Unread int + // 1 if the message has been replied. + IsReplied int + // 1 if the message has been replied to all. + IsRepliedAll int + // 1 if the message has been forwarded. + IsForwarded int + // The time when the message was received as a Unix time. + Time int64 + // The type of the imported message. + Flags int64 + // The labels to apply to the imported message. Must contain at least one system label. + LabelIDs []string +} + +// ImportRes is a response to an import request. +type ImportRes struct { + Res + + Responses []struct { + Name string + Response struct { + Res + MessageID string + } + } +} + +// ImportMsgRes is a response to a single message import request. +type ImportMsgRes struct { + // The error encountered while importing the message, if any. + Error error + // The newly created message ID. + MessageID string +} + +// Import imports messages to the user's account. +func (c *Client) Import(reqs []*ImportMsgReq) (resps []*ImportMsgRes, err error) { + importReq := &ImportReq{Messages: reqs} + + req, w, err := NewMultipartRequest("POST", "/import") + if err != nil { + return + } + + // We will write the request as long as it is sent to the API. + var importRes ImportRes + done := make(chan error, 1) + go (func() { + done <- c.DoJSON(req, &importRes) + })() + + // Write the request. + if err = importReq.WriteTo(w.Writer); err != nil { + return + } + _ = w.Close() + + if err = <-done; err != nil { + return + } + if err = importRes.Err(); err != nil { + return + } + + resps = make([]*ImportMsgRes, len(importRes.Responses)) + for i, r := range importRes.Responses { + resps[i] = &ImportMsgRes{ + Error: r.Response.Err(), + MessageID: r.Response.MessageID, + } + } + + return resps, err +} diff --git a/pkg/pmapi/import_test.go b/pkg/pmapi/import_test.go new file mode 100644 index 00000000..362625a7 --- /dev/null +++ b/pkg/pmapi/import_test.go @@ -0,0 +1,155 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "encoding/json" + "fmt" + "io" + "io/ioutil" + "mime" + "mime/multipart" + "net/http" + "reflect" + "testing" +) + +var testImportReqs = []*ImportMsgReq{ + { + AddressID: "QMJs2dzTx7uqpH5PNgIzjULywU4gO9uMBhEMVFOAVJOoUml54gC0CCHtW9qYwzH-zYbZwMv3MFYncPjW1Usq7Q==", + Body: []byte("Hello World!"), + Unread: 0, + Flags: FlagReceived | FlagImported, + LabelIDs: []string{ArchiveLabel}, + }, +} + +const testImportBody = `{ + "Code": 1001, + "Responses": [{ + "Name": "0", + "Response": {"Code": 1000, "MessageID": "UKjSNz95KubYjrYmfbv1mbIfGxzY6D64mmHmVpWhkeEau-u0PIS4ru5IFMHgX6WjKpWYKCht3oiOtL5-wZChNg=="} + }] +}` + +var testImportRes = &ImportMsgRes{ + Error: nil, + MessageID: "UKjSNz95KubYjrYmfbv1mbIfGxzY6D64mmHmVpWhkeEau-u0PIS4ru5IFMHgX6WjKpWYKCht3oiOtL5-wZChNg==", +} + +func TestClient_Import(t *testing.T) { // nolint[funlen] + s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Ok(t, checkMethodAndPath(r, "POST", "/import")) + + contentType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + t.Error("Expected no error while parsing request content type, got:", err) + } + if contentType != "multipart/form-data" { + t.Errorf("Invalid request content type: expected %v but got %v", "multipart/form-data", contentType) + } + + mr := multipart.NewReader(r.Body, params["boundary"]) + + // First part is metadata. + p, err := mr.NextPart() + if err != nil { + t.Error("Expected no error while reading first part of request body, got:", err) + } + + contentDisp, params, err := mime.ParseMediaType(p.Header.Get("Content-Disposition")) + if err != nil { + t.Error("Expected no error while parsing part content disposition, got:", err) + } + if contentDisp != "form-data" { + t.Errorf("Invalid part content disposition: expected %v but got %v", "form-data", contentType) + } + if params["name"] != "Metadata" { + t.Errorf("Invalid part name: expected %v but got %v", "Metadata", params["name"]) + } + + metadata := map[string]*ImportMsgReq{} + if err := json.NewDecoder(p).Decode(&metadata); err != nil { + t.Error("Expected no error while parsing metadata json, got:", err) + } + + if len(metadata) != 1 { + t.Errorf("Expected metadata to contain exactly one item, got %v", metadata) + } + + req := metadata["0"] + if metadata["0"] == nil { + t.Errorf("Expected metadata to contain one item indexed by 0, got %v", metadata) + } + + // No Body in metadata. + expected := *testImportReqs[0] + expected.Body = nil + if !reflect.DeepEqual(&expected, req) { + t.Errorf("Invalid message metadata: expected %v, got %v", &expected, req) + } + + // Second part is message body. + p, err = mr.NextPart() + if err != nil { + t.Error("Expected no error while reading second part of request body, got:", err) + } + + contentDisp, params, err = mime.ParseMediaType(p.Header.Get("Content-Disposition")) + if err != nil { + t.Error("Expected no error while parsing part content disposition, got:", err) + } + if contentDisp != "form-data" { + t.Errorf("Invalid part content disposition: expected %v but got %v", "form-data", contentType) + } + if params["name"] != "0" { + t.Errorf("Invalid part name: expected %v but got %v", "0", params["name"]) + } + + b, err := ioutil.ReadAll(p) + if err != nil { + t.Error("Expected no error while reading second part body, got:", err) + } + + if string(b) != string(testImportReqs[0].Body) { + t.Errorf("Invalid message body: expected %v but got %v", string(testImportReqs[0].Body), string(b)) + } + + // No more parts. + _, err = mr.NextPart() + if err != io.EOF { + t.Error("Expected no more parts but error was not EOF, got:", err) + } + + fmt.Fprint(w, testImportBody) + })) + defer s.Close() + + imported, err := c.Import(testImportReqs) + if err != nil { + t.Fatal("Expected no error while importing, got:", err) + } + + if len(imported) != 1 { + t.Fatalf("Expected exactly one imported message, got %v", len(imported)) + } + + if !reflect.DeepEqual(testImportRes, imported[0]) { + t.Errorf("Invalid response for imported message: expected %+v but got %+v", testImportRes, imported[0]) + } +} diff --git a/pkg/pmapi/key.go b/pkg/pmapi/key.go new file mode 100644 index 00000000..c8941e02 --- /dev/null +++ b/pkg/pmapi/key.go @@ -0,0 +1,138 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "fmt" + "net/http" + "net/url" + "strings" + + pmcrypto "github.com/ProtonMail/gopenpgp/crypto" +) + +// Flags +const ( + UseToVerifyFlag = 1 << iota + UseToEncryptFlag +) + +type PublicKeyRes struct { + Res + + RecipientType int + MIMEType string + Keys []PublicKey +} + +type PublicKey struct { + Flags int + PublicKey string +} + +// PublicKeys returns the public keys of the given email addresses. +func (c *Client) PublicKeys(emails []string) (keys map[string]*pmcrypto.KeyRing, err error) { + if len(emails) == 0 { + err = fmt.Errorf("pmapi: cannot get public keys: no email address provided") + return + } + keys = map[string]*pmcrypto.KeyRing{} + + for _, email := range emails { + email = url.QueryEscape(email) + + var req *http.Request + if req, err = NewRequest("GET", "/keys?Email="+email, nil); err != nil { + return + } + + var res PublicKeyRes + if err = c.DoJSON(req, &res); err != nil { + return + } + + for _, key := range res.Keys { + if key.Flags&UseToEncryptFlag == UseToEncryptFlag { + var kr *pmcrypto.KeyRing + if kr, err = pmcrypto.ReadArmoredKeyRing(strings.NewReader(key.PublicKey)); err != nil { + return + } + keys[email] = kr + } + } + } + + return keys, err +} + +const ( + RecipientInternal = 1 + RecipientExternal = 2 +) + +// GetPublicKeysForEmail returns all sending public keys for the given email address. +func (c *Client) GetPublicKeysForEmail(email string) (keys []PublicKey, internal bool, err error) { + email = url.QueryEscape(email) + + var req *http.Request + if req, err = NewRequest("GET", "/keys?Email="+email, nil); err != nil { + return + } + + var res PublicKeyRes + if err = c.DoJSON(req, &res); err != nil { + return + } + + internal = res.RecipientType == RecipientInternal + + for _, key := range res.Keys { + if key.Flags&UseToEncryptFlag == UseToEncryptFlag { + keys = append(keys, key) + } + } + return +} + +// KeySalt contains id and salt for key. +type KeySalt struct { + ID, KeySalt string +} + +// KeySaltRes is used to unmarshal API response. +type KeySaltRes struct { + Res + KeySalts []KeySalt +} + +// GetKeySalts sends request to get list of key salts (n.b. locked route). +func (c *Client) GetKeySalts() (keySalts []KeySalt, err error) { + var req *http.Request + if req, err = NewRequest("GET", "/keys/salts", nil); err != nil { + return + } + + var res KeySaltRes + if err = c.DoJSON(req, &res); err != nil { + return + } + + keySalts = res.KeySalts + + return +} diff --git a/pkg/pmapi/keyring.go b/pkg/pmapi/keyring.go new file mode 100644 index 00000000..f2ea90d1 --- /dev/null +++ b/pkg/pmapi/keyring.go @@ -0,0 +1,295 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "sync" + + pmcrypto "github.com/ProtonMail/gopenpgp/crypto" +) + +// clearableKey is a region of memory intended to hold a private key and which can be securely +// cleared by calling clear(). +type clearableKey []byte + +// UnmarshalJSON Removes quotation and unescapes CR, LF. +func (pk *clearableKey) UnmarshalJSON(b []byte) (err error) { + b = bytes.Trim(b, "\"") + b = bytes.ReplaceAll(b, []byte("\\n"), []byte("\n")) + b = bytes.ReplaceAll(b, []byte("\\r"), []byte("\r")) + *pk = b + return +} + +// clear irreversibly destroys the full range of `clearableKey` by filling it with zeros to ensure +// nobody can see what was in there (e.g. while waiting for the garbage collector to clean it up). +func (pk *clearableKey) clear() { + for b := range *pk { + (*pk)[b] = 0 + } +} + +type PMKey struct { + ID string + Version int + Flags int + Fingerprint string + Primary int + Token *string `json:",omitempty"` + Signature *string `json:",omitempty"` +} + +type PMKeys struct { + Keys []PMKey + KeyRing *pmcrypto.KeyRing +} + +func (k *PMKeys) UnmarshalJSON(b []byte) (err error) { + var rawKeys []struct { + PMKey + PrivateKey clearableKey + } + if err = json.Unmarshal(b, &rawKeys); err != nil { + return + } + + k.KeyRing = &pmcrypto.KeyRing{} + for _, rawKey := range rawKeys { + err = k.KeyRing.ReadFrom(bytes.NewReader(rawKey.PrivateKey), true) + rawKey.PrivateKey.clear() + if err != nil { + return + } + k.Keys = append(k.Keys, rawKey.PMKey) + } + if len(k.Keys) > 0 { + k.KeyRing.FirstKeyID = k.Keys[0].ID + } + return +} + +// unlockKeyRing tries to unlock them with the provided keyRing using the token +// and if the token is not available it will use passphrase. It will not fail +// if keyring contains at least one unlocked private key. +func (k *PMKeys) unlockKeyRing(userKeyring *pmcrypto.KeyRing, passphrase []byte, locker sync.Locker) (err error) { + locker.Lock() + defer locker.Unlock() + + if k == nil { + err = errors.New("keys is a nil object") + return + } + + for _, key := range k.Keys { + if key.Token == nil || key.Signature == nil { + if err = unlockKeyRingNoErrorWhenAlreadyUnlocked(k.KeyRing, passphrase); err != nil { + return + } + continue + } + + message, err := pmcrypto.NewPGPMessageFromArmored(*key.Token) + if err != nil { + return err + } + + signature, err := pmcrypto.NewPGPSignatureFromArmored(*key.Signature) + if err != nil { + return err + } + + if userKeyring == nil { + return errors.New("userkey required to decrypt tokens but wasn't provided") + } + token, err := userKeyring.Decrypt(message, nil, 0) + if err != nil { + return err + } + + err = userKeyring.VerifyDetached(token, signature, 0) + if err != nil { + return err + } + + err = unlockKeyRingNoErrorWhenAlreadyUnlocked(k.KeyRing, token.GetBinary()) + if err != nil { + return fmt.Errorf("wrong token: %v", err) + } + } + + return nil +} + +type unlockError struct { + error +} + +func (err *unlockError) Error() string { + return "Invalid mailbox password (" + err.error.Error() + ")" +} + +// IsUnlockError checks whether the error is due to failure to unlock (which is represented by an unexported type). +func IsUnlockError(err error) bool { + _, ok := err.(*unlockError) + return ok +} + +func unlockKeyRingNoErrorWhenAlreadyUnlocked(kr *pmcrypto.KeyRing, passphrase []byte) (err error) { + if err = kr.Unlock(passphrase); err != nil { + // Do not fail if it has already unlocked keys. + hasUnlockedKey := false + for _, e := range kr.GetEntities() { + if e.PrivateKey != nil && !e.PrivateKey.Encrypted { + hasUnlockedKey = true + break + } + for _, se := range e.Subkeys { + if se.PrivateKey != nil && (!se.Sig.FlagsValid || se.Sig.FlagEncryptStorage || se.Sig.FlagEncryptCommunications) && !e.PrivateKey.Encrypted { + hasUnlockedKey = true + break + } + } + if hasUnlockedKey { + break + } + } + if !hasUnlockedKey { + err = &unlockError{err} + return + } + err = nil + } + return +} + +// ErrNoKeyringAvailable represents an error caused by a keyring being nil or having no entities. +var ErrNoKeyringAvailable = errors.New("no keyring available") + +func (c *Client) encrypt(plain string, signer *pmcrypto.KeyRing) (armored string, err error) { + return encrypt(c.kr, plain, signer) +} + +func encrypt(encrypter *pmcrypto.KeyRing, plain string, signer *pmcrypto.KeyRing) (armored string, err error) { + if encrypter == nil || encrypter.FirstKey() == nil { + return "", ErrNoKeyringAvailable + } + plainMessage := pmcrypto.NewPlainMessageFromString(plain) + // We use only primary key to encrypt the message. Our keyring contains all keys (primary, old and deacivated ones). + pgpMessage, err := encrypter.FirstKey().Encrypt(plainMessage, signer) + if err != nil { + return + } + return pgpMessage.GetArmored() +} + +func (c *Client) decrypt(armored string) (plain string, err error) { + return decrypt(c.kr, armored) +} + +func decrypt(decrypter *pmcrypto.KeyRing, armored string) (plainBody string, err error) { + if decrypter == nil { + return "", ErrNoKeyringAvailable + } + pgpMessage, err := pmcrypto.NewPGPMessageFromArmored(armored) + if err != nil { + return + } + plainMessage, err := decrypter.Decrypt(pgpMessage, nil, 0) + if err != nil { + return + } + return plainMessage.GetString(), nil +} + +func (c *Client) sign(plain string) (armoredSignature string, err error) { + if c.kr == nil { + return "", ErrNoKeyringAvailable + } + plainMessage := pmcrypto.NewPlainMessageFromString(plain) + pgpSignature, err := c.kr.SignDetached(plainMessage) + if err != nil { + return + } + return pgpSignature.GetArmored() +} + +func (c *Client) verify(plain, amroredSignature string) (err error) { + plainMessage := pmcrypto.NewPlainMessageFromString(plain) + pgpSignature, err := pmcrypto.NewPGPSignatureFromArmored(amroredSignature) + if err != nil { + return + } + verifyTime := int64(0) // By default it will use current timestamp. + return c.kr.VerifyDetached(plainMessage, pgpSignature, verifyTime) +} + +func encryptAttachment(kr *pmcrypto.KeyRing, data io.Reader, filename string) (encrypted io.Reader, err error) { + if kr == nil || kr.FirstKey() == nil { + return nil, ErrNoKeyringAvailable + } + dataBytes, err := ioutil.ReadAll(data) + if err != nil { + return + } + plainMessage := pmcrypto.NewPlainMessage(dataBytes) + // We use only primary key to encrypt the message. Our keyring contains all keys (primary, old and deacivated ones). + pgpSplitMessage, err := kr.FirstKey().EncryptAttachment(plainMessage, filename) + if err != nil { + return + } + packets := append(pgpSplitMessage.KeyPacket, pgpSplitMessage.DataPacket...) + return bytes.NewReader(packets), nil +} + +func decryptAttachment(kr *pmcrypto.KeyRing, keyPackets []byte, data io.Reader) (decrypted io.Reader, err error) { + if kr == nil { + return nil, ErrNoKeyringAvailable + } + dataBytes, err := ioutil.ReadAll(data) + if err != nil { + return + } + pgpSplitMessage := pmcrypto.NewPGPSplitMessage(keyPackets, dataBytes) + plainMessage, err := kr.DecryptAttachment(pgpSplitMessage) + if err != nil { + return + } + return plainMessage.NewReader(), nil +} + +func signAttachment(encrypter *pmcrypto.KeyRing, data io.Reader) (signature io.Reader, err error) { + if encrypter == nil { + return nil, ErrNoKeyringAvailable + } + dataBytes, err := ioutil.ReadAll(data) + if err != nil { + return + } + plainMessage := pmcrypto.NewPlainMessage(dataBytes) + sig, err := encrypter.SignDetached(plainMessage) + if err != nil { + return + } + return bytes.NewReader(sig.GetBinary()), nil +} diff --git a/pkg/pmapi/keyring_test.go b/pkg/pmapi/keyring_test.go new file mode 100644 index 00000000..1ec07ad0 --- /dev/null +++ b/pkg/pmapi/keyring_test.go @@ -0,0 +1,94 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "encoding/json" + "strings" + "sync" + "testing" + + pmcrypto "github.com/ProtonMail/gopenpgp/crypto" + "github.com/stretchr/testify/assert" +) + +func loadPMKeys(jsonKeys string) (keys *PMKeys) { + _ = json.Unmarshal([]byte(jsonKeys), &keys) + return +} + +func TestPMKeys_GetKeyRingAndUnlock(t *testing.T) { + addrKeysWithTokens := loadPMKeys(readTestFile("keyring_addressKeysWithTokens_JSON", false)) + addrKeysWithoutTokens := loadPMKeys(readTestFile("keyring_addressKeysWithoutTokens_JSON", false)) + addrKeysPrimaryHasToken := loadPMKeys(readTestFile("keyring_addressKeysPrimaryHasToken_JSON", false)) + addrKeysSecondaryHasToken := loadPMKeys(readTestFile("keyring_addressKeysSecondaryHasToken_JSON", false)) + + userKey, err := pmcrypto.ReadArmoredKeyRing(strings.NewReader(readTestFile("keyring_userKey", false))) + assert.NoError(t, err, "Expected not to receive an error unlocking user key") + + type args struct { + userKeyring *pmcrypto.KeyRing + passphrase []byte + } + tests := []struct { + name string + keys *PMKeys + args args + }{ + { + name: "AddressKeys locked with tokens", + keys: addrKeysWithTokens, + args: args{userKeyring: userKey, passphrase: []byte("testpassphrase")}, + }, + { + name: "AddressKeys locked with passphrase, not tokens", + keys: addrKeysWithoutTokens, + args: args{userKeyring: userKey, passphrase: []byte("testpassphrase")}, + }, + { + name: "AddressKeys, primary locked with token, secondary with passphrase", + keys: addrKeysPrimaryHasToken, + args: args{userKeyring: userKey, passphrase: []byte("testpassphrase")}, + }, + { + name: "AddressKeys, primary locked with passphrase, secondary with token", + keys: addrKeysSecondaryHasToken, + args: args{userKeyring: userKey, passphrase: []byte("testpassphrase")}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempLocker := &sync.Mutex{} + + err := tt.keys.unlockKeyRing(tt.args.userKeyring, tt.args.passphrase, tempLocker) // nolint[scopelint] + if !assert.NoError(t, err) { + return + } + + // assert at least one key has been decrypted + atLeastOneDecrypted := false + for _, e := range tt.keys.KeyRing.GetEntities() { // nolint[scopelint] + if !e.PrivateKey.Encrypted { + atLeastOneDecrypted = true + break + } + } + assert.True(t, atLeastOneDecrypted) + }) + } +} diff --git a/pkg/pmapi/labels.go b/pkg/pmapi/labels.go new file mode 100644 index 00000000..60294f98 --- /dev/null +++ b/pkg/pmapi/labels.go @@ -0,0 +1,177 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import "fmt" + +// System labels +const ( + InboxLabel = "0" + AllDraftsLabel = "1" + AllSentLabel = "2" + TrashLabel = "3" + SpamLabel = "4" + AllMailLabel = "5" + ArchiveLabel = "6" + SentLabel = "7" + DraftLabel = "8" + StarredLabel = "10" + + LabelTypeMailbox = 1 + LabelTypeContactGroup = 2 +) + +// IsSystemLabel checks if a label is a pre-defined system label. +func IsSystemLabel(label string) bool { + switch label { + case InboxLabel, DraftLabel, SentLabel, TrashLabel, SpamLabel, ArchiveLabel, StarredLabel, AllMailLabel, AllSentLabel, AllDraftsLabel: + return true + } + return false +} + +// LabelColors provides the RGB values of the available label colors. +var LabelColors = []string{ //nolint[gochecknoglobals] + "#7272a7", + "#cf5858", + "#c26cc7", + "#7569d1", + "#69a9d1", + "#5ec7b7", + "#72bb75", + "#c3d261", + "#e6c04c", + "#e6984c", + "#8989ac", + "#cf7e7e", + "#c793ca", + "#9b94d1", + "#a8c4d5", + "#97c9c1", + "#9db99f", + "#c6cd97", + "#e7d292", + "#dfb286", +} + +type LabelAction int + +const ( + RemoveLabel LabelAction = iota + AddLabel +) + +// Label for message. +type Label struct { + ID string + Name string + Color string + Order int `json:",omitempty"` + Display int // Not used for now, leave it empty. + Exclusive int + Type int + Notify int +} + +type LabelListRes struct { + Res + Labels []*Label +} + +func (c *Client) ListLabels() (labels []*Label, err error) { + return c.ListLabelType(LabelTypeMailbox) +} + +func (c *Client) ListContactGroups() (labels []*Label, err error) { + return c.ListLabelType(LabelTypeContactGroup) +} + +// ListLabelType lists all labels created by the user. +func (c *Client) ListLabelType(labelType int) (labels []*Label, err error) { + req, err := NewRequest("GET", fmt.Sprintf("/labels?%d", labelType), nil) + if err != nil { + return + } + + var res LabelListRes + if err = c.DoJSON(req, &res); err != nil { + return + } + + labels, err = res.Labels, res.Err() + return +} + +type LabelReq struct { + *Label +} + +type LabelRes struct { + Res + Label *Label +} + +// CreateLabel creates a new label. +func (c *Client) CreateLabel(label *Label) (created *Label, err error) { + labelReq := &LabelReq{label} + req, err := NewJSONRequest("POST", "/labels", labelReq) + if err != nil { + return + } + + var res LabelRes + if err = c.DoJSON(req, &res); err != nil { + return + } + + created, err = res.Label, res.Err() + return +} + +// UpdateLabel updates a label. +func (c *Client) UpdateLabel(label *Label) (updated *Label, err error) { + labelReq := &LabelReq{label} + req, err := NewJSONRequest("PUT", "/labels/"+label.ID, labelReq) + if err != nil { + return + } + + var res LabelRes + if err = c.DoJSON(req, &res); err != nil { + return + } + + updated, err = res.Label, res.Err() + return +} + +// DeleteLabel deletes a label. +func (c *Client) DeleteLabel(id string) (err error) { + req, err := NewRequest("DELETE", "/labels/"+id, nil) + if err != nil { + return + } + + var res Res + if err = c.DoJSON(req, &res); err != nil { + return + } + + err = res.Err() + return +} diff --git a/pkg/pmapi/labels_test.go b/pkg/pmapi/labels_test.go new file mode 100644 index 00000000..6479f501 --- /dev/null +++ b/pkg/pmapi/labels_test.go @@ -0,0 +1,186 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "reflect" + "testing" +) + +const testLabelsBody = `{ + "Labels": [ + { + "ID": "LLz8ysmVxwr4dF6mWpClePT0SpSWOEvzTdq17RydSl4ndMckvY1K63HeXDzn03BJQwKYvgf-eWT8Qfd9WVuIEQ==", + "Name": "CroutonMail is awesome :)", + "Color": "#7272a7", + "Display": 0, + "Order": 1, + "Type": 1 + }, + { + "ID": "BvbqbySUPo9uWW_eR8tLA13NUsQMz3P4Zhw4UnpvrKqURnrHlE6L2Au0nplHfHlVXFgGz4L4hJ9-BYllOL-L5g==", + "Name": "Royal sausage", + "Color": "#cf5858", + "Display": 1, + "Order": 2, + "Type": 1 + } + ], + "Code": 1000 +} +` + +var testLabels = []*Label{ + {ID: "LLz8ysmVxwr4dF6mWpClePT0SpSWOEvzTdq17RydSl4ndMckvY1K63HeXDzn03BJQwKYvgf-eWT8Qfd9WVuIEQ==", Name: "CroutonMail is awesome :)", Color: "#7272a7", Order: 1, Display: 0, Type: LabelTypeMailbox}, + {ID: "BvbqbySUPo9uWW_eR8tLA13NUsQMz3P4Zhw4UnpvrKqURnrHlE6L2Au0nplHfHlVXFgGz4L4hJ9-BYllOL-L5g==", Name: "Royal sausage", Color: "#cf5858", Order: 2, Display: 1, Type: LabelTypeMailbox}, +} + +var testLabelReq = LabelReq{&Label{ + Name: "sava", + Color: "#c26cc7", + Display: 1, +}} + +const testCreateLabelBody = `{ + "Label": { + "ID": "otkpEZzG--8dMXvwyLXLQWB72hhBhNGzINjH14rUDfywvOyeN01cDxDrS3Koifxf6asA7Xcwtldm0r_MCmWiAQ==", + "Name": "sava", + "Color": "#c26cc7", + "Display": 1, + "Order": 3, + "Type": 1 + }, + "Code": 1000 +} +` + +var testLabelCreated = &Label{ + ID: "otkpEZzG--8dMXvwyLXLQWB72hhBhNGzINjH14rUDfywvOyeN01cDxDrS3Koifxf6asA7Xcwtldm0r_MCmWiAQ==", + Name: "sava", + Color: "#c26cc7", + Order: 3, + Display: 1, + Type: LabelTypeMailbox, +} + +const testDeleteLabelBody = `{ + "Code": 1000 +} +` + +func TestClient_ListLabels(t *testing.T) { + s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Ok(t, checkMethodAndPath(r, "GET", "/labels?1")) + + fmt.Fprint(w, testLabelsBody) + })) + defer s.Close() + + labels, err := c.ListLabels() + if err != nil { + t.Fatal("Expected no error while listing labels, got:", err) + } + + if !reflect.DeepEqual(labels, testLabels) { + for i, l := range testLabels { + t.Errorf("expected %d: %#v\n", i, l) + } + for i, l := range labels { + t.Errorf("got %d: %#v\n", i, l) + } + t.Fatalf("Not same") + } +} + +func TestClient_CreateLabel(t *testing.T) { + s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Ok(t, checkMethodAndPath(r, "POST", "/labels")) + + body := &bytes.Buffer{} + _, err := body.ReadFrom(r.Body) + Ok(t, err) + + if bytes.Contains(body.Bytes(), []byte("Order")) { + t.Fatal("Body contains `Order`: ", body.String()) + } + + var labelReq LabelReq + if err := json.NewDecoder(body).Decode(&labelReq); err != nil { + t.Error("Expecting no error while reading request body, got:", err) + } + if !reflect.DeepEqual(testLabelReq.Label, labelReq.Label) { + t.Errorf("Invalid label request: expected %+v but got %+v", testLabelReq.Label, labelReq.Label) + } + + fmt.Fprint(w, testCreateLabelBody) + })) + defer s.Close() + + created, err := c.CreateLabel(testLabelReq.Label) + if err != nil { + t.Fatal("Expected no error while creating label, got:", err) + } + + if !reflect.DeepEqual(created, testLabelCreated) { + t.Fatalf("Invalid created label: expected %+v, got %+v", testLabelCreated, created) + } +} + +func TestClient_UpdateLabel(t *testing.T) { + s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Ok(t, checkMethodAndPath(r, "PUT", "/labels/"+testLabelCreated.ID)) + + var labelReq LabelReq + if err := json.NewDecoder(r.Body).Decode(&labelReq); err != nil { + t.Error("Expecting no error while reading request body, got:", err) + } + if !reflect.DeepEqual(testLabelCreated, labelReq.Label) { + t.Errorf("Invalid label request: expected %+v but got %+v", testLabelCreated, labelReq.Label) + } + + fmt.Fprint(w, testCreateLabelBody) + })) + defer s.Close() + + updated, err := c.UpdateLabel(testLabelCreated) + if err != nil { + t.Fatal("Expected no error while updating label, got:", err) + } + + if !reflect.DeepEqual(updated, testLabelCreated) { + t.Fatalf("Invalid updated label: expected %+v, got %+v", testLabelCreated, updated) + } +} + +func TestClient_DeleteLabel(t *testing.T) { + s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Ok(t, checkMethodAndPath(r, "DELETE", "/labels/"+testLabelCreated.ID)) + + fmt.Fprint(w, testDeleteLabelBody) + })) + defer s.Close() + + err := c.DeleteLabel(testLabelCreated.ID) + if err != nil { + t.Fatal("Expected no error while deleting label, got:", err) + } +} diff --git a/pkg/pmapi/messages.go b/pkg/pmapi/messages.go new file mode 100644 index 00000000..f3a68943 --- /dev/null +++ b/pkg/pmapi/messages.go @@ -0,0 +1,810 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "net/mail" + "net/url" + "strconv" + "strings" + + pmcrypto "github.com/ProtonMail/gopenpgp/crypto" + "golang.org/x/crypto/openpgp/packet" +) + +// Header types. +const ( + MessageHeader = "-----BEGIN PGP MESSAGE-----" + MessageTail = "-----END PGP MESSAGE-----" + MessageHeaderLegacy = "---BEGIN ENCRYPTED MESSAGE---" + MessageTailLegacy = "---END ENCRYPTED MESSAGE---" + RandomKeyHeader = "---BEGIN ENCRYPTED RANDOM KEY---" + RandomKeyTail = "---END ENCRYPTED RANDOM KEY---" +) + +// Sort types. +const ( + SortByTo = "To" + SortByFrom = "From" + SortBySubject = "Subject" + SortBySize = "Size" + SortByTime = "Time" + SortByID = "ID" + SortDesc = true + SortAsc = false +) + +// Message actions. +const ( + ActionReply = 0 + ActionReplyAll = 1 + ActionForward = 2 +) + +// Message flag definitions. +const ( + FlagReceived = 1 + FlagSent = 2 + FlagInternal = 4 + FlagE2E = 8 + FlagAuto = 16 + FlagReplied = 32 + FlagRepliedAll = 64 + FlagForwarded = 128 + + FlagAutoreplied = 256 + FlagImported = 512 + FlagOpened = 1024 + FlagReceiptSent = 2048 +) + +// Draft flags. +const ( + FlagReceiptRequest = 1 << 16 + FlagPublicKey = 1 << 17 + FlagSign = 1 << 18 +) + +// Spam flags. +const ( + FlagSpfFail = 1 << 24 + FlagDkimFail = 1 << 25 + FlagDmarcFail = 1 << 26 + FlagHamManual = 1 << 27 + FlagSpamAuto = 1 << 28 + FlagSpamManual = 1 << 29 + FlagPhishingAuto = 1 << 30 + FlagPhishingManual = 1 << 31 +) + +// Message flag masks. +const ( + FlagMaskGeneral = 4095 + FlagMaskDraft = FlagReceiptRequest * 7 + FlagMaskSpam = FlagSpfFail * 255 + FlagMask = FlagMaskGeneral | FlagMaskDraft | FlagMaskSpam +) + +// INTERNAL, AUTO are immutable. E2E is immutable except for drafts on send. +const ( + FlagMaskAdd = 4067 + (16777216 * 168) +) + +// Content types. +const ( + ContentTypeMultipartMixed = "multipart/mixed" + ContentTypeMultipartEncrypted = "multipart/encrypted" + ContentTypePlainText = "text/plain" + ContentTypeHTML = "text/html" +) + +// LabelsOperation is the operation to apply to labels. +type LabelsOperation int + +const ( + KeepLabels LabelsOperation = iota // Do nothing. + ReplaceLabels // Replace current labels with new ones. + AddLabels // Add new labels to current ones. + RemoveLabels // Remove specified labels from current ones. +) + +const ( + MessageTypeInbox int = iota + MessageTypeDraft + MessageTypeSent + MessageTypeInboxAndSent +) + +// Due to API limitations, we shouldn't make requests with more than 100 message IDs at a time. +const messageIDPageSize = 100 + +// Message structure. +type Message struct { + ID string `json:",omitempty"` + Order int64 `json:",omitempty"` + ConversationID string `json:",omitempty"` // only filter + Subject string + Unread int + Type int + Flags int64 + Sender *mail.Address + ReplyTo *mail.Address `json:",omitempty"` + ReplyTos []*mail.Address `json:",omitempty"` + ToList []*mail.Address + CCList []*mail.Address + BCCList []*mail.Address + Time int64 // Unix time + Size int64 + NumAttachments int + ExpirationTime int64 // Unix time + SpamScore int + AddressID string + Body string `json:",omitempty"` + Attachments []*Attachment + LabelIDs []string + ExternalID string + Header mail.Header + MIMEType string +} + +// NewMessage initializes a new message. +func NewMessage() *Message { + return &Message{ + ToList: []*mail.Address{}, + CCList: []*mail.Address{}, + BCCList: []*mail.Address{}, + Attachments: []*Attachment{}, + LabelIDs: []string{}, + } +} + +// Define a new type to prevent MarshalJSON/UnmarshalJSON infinite loops. +type message Message + +type rawMessage struct { + message + + Header string `json:",omitempty"` +} + +func (m *Message) MarshalJSON() ([]byte, error) { + var raw rawMessage + raw.message = message(*m) + + b := &bytes.Buffer{} + _ = http.Header(m.Header).Write(b) + raw.Header = b.String() + + return json.Marshal(&raw) +} + +func (m *Message) UnmarshalJSON(b []byte) error { + var raw rawMessage + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + + *m = Message(raw.message) + + if raw.Header != "" && raw.Header != "(No Header)" { + msg, err := mail.ReadMessage(strings.NewReader(raw.Header + "\r\n\r\n")) + if err == nil { + m.Header = msg.Header + } + } + + return nil +} + +func (m *Message) IsBodyEncrypted() bool { + trimmedBody := strings.TrimSpace(m.Body) + return strings.HasPrefix(trimmedBody, MessageHeader) && + strings.HasSuffix(trimmedBody, MessageTail) +} + +func (m *Message) IsLegacyMessage() bool { + return strings.Contains(m.Body, RandomKeyHeader) && + strings.Contains(m.Body, RandomKeyTail) && + strings.Contains(m.Body, MessageHeaderLegacy) && + strings.Contains(m.Body, MessageTailLegacy) && + strings.Contains(m.Body, MessageHeader) && + strings.Contains(m.Body, MessageTail) +} + +func (m *Message) Decrypt(kr *pmcrypto.KeyRing) (err error) { + if m.IsLegacyMessage() { + return m.DecryptLegacy(kr) + } + + if !m.IsBodyEncrypted() { + return + } + + armored := strings.TrimSpace(m.Body) + body, err := decrypt(kr, armored) + if err != nil { + return + } + + m.Body = body + return +} + +func (m *Message) DecryptLegacy(kr *pmcrypto.KeyRing) (err error) { + randomKeyStart := strings.Index(m.Body, RandomKeyHeader) + len(RandomKeyHeader) + randomKeyEnd := strings.Index(m.Body, RandomKeyTail) + randomKey := m.Body[randomKeyStart:randomKeyEnd] + + signedKey, err := decrypt(kr, strings.TrimSpace(randomKey)) + if err != nil { + return + } + bytesKey, err := decodeBase64UTF8(signedKey) + if err != nil { + return + } + + messageStart := strings.Index(m.Body, MessageHeaderLegacy) + len(MessageHeaderLegacy) + messageEnd := strings.Index(m.Body, MessageTailLegacy) + message := m.Body[messageStart:messageEnd] + bytesMessage, err := decodeBase64UTF8(message) + if err != nil { + return + } + + block, err := aes.NewCipher(bytesKey) + if err != nil { + return + } + + prefix := make([]byte, block.BlockSize()+2) + bytesMessageReader := bytes.NewReader(bytesMessage) + + _, err = io.ReadFull(bytesMessageReader, prefix) + if err != nil { + return + } + s := packet.NewOCFBDecrypter(block, prefix, packet.OCFBResync) + if s == nil { + err = errors.New("pmapi: incorrect key for legacy decryption") + return + } + + reader := cipher.StreamReader{S: s, R: bytesMessageReader} + buf := new(bytes.Buffer) + _, _ = buf.ReadFrom(reader) + plaintextBytes := buf.Bytes() + + plaintext := "" + for i := 0; i < len(plaintextBytes); i++ { + plaintext += string(plaintextBytes[i]) + } + bytesPlaintext, err := decodeBase64UTF8(plaintext) + if err != nil { + return + } + + m.Body = string(bytesPlaintext) + return err +} + +func decodeBase64UTF8(input string) (output []byte, err error) { + input = strings.TrimSpace(input) + decodedMessage, err := base64.StdEncoding.DecodeString(input) + if err != nil { + return + } + utf8DecodedMessage := []rune(string(decodedMessage)) + output = make([]byte, len(utf8DecodedMessage)) + for i := 0; i < len(utf8DecodedMessage); i++ { + output[i] = byte(int(utf8DecodedMessage[i])) + } + return +} + +func (m *Message) Encrypt(encrypter, signer *pmcrypto.KeyRing) (err error) { + if m.IsBodyEncrypted() { + err = errors.New("pmapi: trying to encrypt an already encrypted message") + return + } + + m.Body, err = encrypt(encrypter, m.Body, signer) + return +} + +func (m *Message) Has(flag int64) bool { + return (m.Flags & flag) == flag +} + +// MessagesCount contains message counts for one label. +type MessagesCount struct { + LabelID string + Total int + Unread int +} + +// MessagesFilter contains fields to filter messages. +type MessagesFilter struct { + Page int + PageSize int + Limit int + LabelID string + Sort string // Time by default (Time, To, From, Subject, Size). + Desc *bool + Begin int64 // Unix time. + End int64 // Unix time. + BeginID string + EndID string + Keyword string + To string + From string + Subject string + ConversationID string + AddressID string + ID []string + Attachments *bool + Unread *bool + ExternalID string // MIME Message-Id (only valid for messages). + AutoWildcard *bool +} + +func (filter *MessagesFilter) urlValues() url.Values { // nolint[funlen] + v := url.Values{} + + if filter.Page != 0 { + v.Set("Page", strconv.Itoa(filter.Page)) + } + if filter.PageSize != 0 { + v.Set("PageSize", strconv.Itoa(filter.PageSize)) + } + if filter.Limit != 0 { + v.Set("Limit", strconv.Itoa(filter.Limit)) + } + if filter.LabelID != "" { + v.Set("LabelID", filter.LabelID) + } + if filter.Sort != "" { + v.Set("Sort", filter.Sort) + } + if filter.Desc != nil { + if *filter.Desc { + v.Set("Desc", "1") + } else { + v.Set("Desc", "0") + } + } + if filter.Begin != 0 { + v.Set("Begin", strconv.Itoa(int(filter.Begin))) + } + if filter.End != 0 { + v.Set("End", strconv.Itoa(int(filter.End))) + } + if filter.BeginID != "" { + v.Set("BeginID", filter.BeginID) + } + if filter.EndID != "" { + v.Set("EndID", filter.EndID) + } + if filter.Keyword != "" { + v.Set("Keyword", filter.Keyword) + } + if filter.To != "" { + v.Set("To", filter.To) + } + if filter.From != "" { + v.Set("From", filter.From) + } + if filter.Subject != "" { + v.Set("Subject", filter.Subject) + } + if filter.ConversationID != "" { + v.Set("ConversationID", filter.ConversationID) + } + if filter.AddressID != "" { + v.Set("AddressID", filter.AddressID) + } + if len(filter.ID) > 0 { + for _, id := range filter.ID { + v.Add("ID[]", id) + } + } + if filter.Attachments != nil { + if *filter.Attachments { + v.Set("Attachments", "1") + } else { + v.Set("Attachments", "0") + } + } + if filter.Unread != nil { + if *filter.Unread { + v.Set("Unread", "1") + } else { + v.Set("Unread", "0") + } + } + if filter.ExternalID != "" { + v.Set("ExternalID", filter.ExternalID) + } + if filter.AutoWildcard != nil { + if *filter.AutoWildcard { + v.Set("AutoWildcard", "1") + } else { + v.Set("AutoWildcard", "0") + } + } + + return v +} + +type MessagesListRes struct { + Res + + Total int + Messages []*Message +} + +// ListMessages gets message metadata. +func (c *Client) ListMessages(filter *MessagesFilter) (msgs []*Message, total int, err error) { + req, err := NewRequest("GET", "/messages", nil) + if err != nil { + return + } + + req.URL.RawQuery = filter.urlValues().Encode() + var res MessagesListRes + if err = c.DoJSON(req, &res); err != nil { + // If the URI was too long and we searched with IDs, we will try again without the API IDs. + if strings.Contains(err.Error(), "api returned: 414") && len(filter.ID) > 0 { + filter.ID = []string{} + return c.ListMessages(filter) + } + return + } + + msgs, total, err = res.Messages, res.Total, res.Err() + return +} + +type MessagesCountsRes struct { + Res + + Counts []*MessagesCount +} + +// CountMessages counts messages by label. +func (c *Client) CountMessages(addressID string) (counts []*MessagesCount, err error) { + reqURL := "/messages/count" + if addressID != "" { + reqURL += ("?AddressID=" + addressID) + } + req, err := NewRequest("GET", reqURL, nil) + if err != nil { + return + } + + var res MessagesCountsRes + if err = c.DoJSON(req, &res); err != nil { + return + } + + counts, err = res.Counts, res.Err() + return +} + +type MessageRes struct { + Res + + Message *Message +} + +// GetMessage retrieves a message. +func (c *Client) GetMessage(id string) (msg *Message, err error) { + req, err := NewRequest("GET", "/messages/"+id, nil) + if err != nil { + return + } + + var res MessageRes + if err = c.DoJSON(req, &res); err != nil { + return + } + + msg, err = res.Message, res.Err() + return +} + +type SendMessageReq struct { + ExpirationTime int64 `json:",omitempty"` + // AutoSaveContacts int `json:",omitempty"` + + // Data for encrypted recipients. + Packages []*MessagePackage +} + +// Message package types. +const ( + InternalPackage = 1 + EncryptedOutsidePackage = 2 + ClearPackage = 4 + PGPInlinePackage = 8 + PGPMIMEPackage = 16 + ClearMIMEPackage = 32 +) + +// Signature types. +const ( + NoSignature = 0 + YesSignature = 1 +) + +type MessagePackage struct { + Addresses map[string]*MessageAddress + Type int + MIMEType string + Body string // base64-encoded encrypted data packet. + BodyKey AlgoKey // base64-encoded session key (only if cleartext recipients). + AttachmentKeys map[string]AlgoKey // Only include if cleartext & attachments. +} + +type MessageAddress struct { + Type int + BodyKeyPacket string // base64-encoded key packet. + Signature int // 0 = None, 1 = Detached, 2 = Attached/Armored + AttachmentKeyPackets map[string]string +} + +type AlgoKey struct { + Key string + Algorithm string +} + +type SendMessageRes struct { + Res + + Sent *Message + + // Parent is only present if the sent message has a parent (reply/reply all/forward). + Parent *Message +} + +func (c *Client) SendMessage(id string, sendReq *SendMessageReq) (sent, parent *Message, err error) { + if id == "" { + err = errors.New("pmapi: cannot send message with an empty id") + return + } + + if sendReq.Packages == nil { + sendReq.Packages = []*MessagePackage{} + } + + req, err := NewJSONRequest("POST", "/messages/"+id, sendReq) + if err != nil { + return + } + + var res SendMessageRes + if err = c.DoJSON(req, &res); err != nil { + return + } + + sent, parent, err = res.Sent, res.Parent, res.Err() + return +} + +const ( + DraftActionReply = 0 + DraftActionReplyAll = 1 + DraftActionForward = 2 +) + +type DraftReq struct { + Message *Message + ParentID string `json:",omitempty"` + Action int + AttachmentKeyPackets []string +} + +func (c *Client) CreateDraft(m *Message, parent string, action int) (created *Message, err error) { + createReq := &DraftReq{Message: m, ParentID: parent, Action: action, AttachmentKeyPackets: []string{}} + + req, err := NewJSONRequest("POST", "/messages", createReq) + if err != nil { + return + } + + var res MessageRes + if err = c.DoJSON(req, &res); err != nil { + return + } + + created, err = res.Message, res.Err() + return +} + +type MessagesActionReq struct { + IDs []string +} + +type MessagesActionRes struct { + Res + + Responses []struct { + ID string + Response Res + } +} + +func (res MessagesActionRes) Err() error { + if err := res.Res.Err(); err != nil { + return err + } + + for _, msgRes := range res.Responses { + if err := msgRes.Response.Err(); err != nil { + return err + } + } + + return nil +} + +// doMessagesAction performs paged requests to doMessagesActionInner. +// This can eventually be done in parallel though. +func (c *Client) doMessagesAction(action string, ids []string) (err error) { + for len(ids) > messageIDPageSize { + var requestIDs []string + requestIDs, ids = ids[:messageIDPageSize], ids[messageIDPageSize:] + if err = c.doMessagesActionInner(action, requestIDs); err != nil { + return + } + } + + return c.doMessagesActionInner(action, ids) +} + +// doMessagesActionInner is the non-paged inner method of doMessagesAction. +// You should not call this directly unless you know what you are doing (it can overload the server). +func (c *Client) doMessagesActionInner(action string, ids []string) (err error) { + actionReq := &MessagesActionReq{IDs: ids} + req, err := NewJSONRequest("PUT", "/messages/"+action, actionReq) + if err != nil { + return + } + + var res MessagesActionRes + if err = c.DoJSON(req, &res); err != nil { + return + } + + err = res.Err() + + return +} + +func (c *Client) MarkMessagesRead(ids []string) error { + return c.doMessagesAction("read", ids) +} + +func (c *Client) MarkMessagesUnread(ids []string) error { + return c.doMessagesAction("unread", ids) +} + +func (c *Client) DeleteMessages(ids []string) error { + return c.doMessagesAction("delete", ids) +} + +func (c *Client) UndeleteMessages(ids []string) error { + return c.doMessagesAction("undelete", ids) +} + +type LabelMessagesReq struct { + LabelID string + IDs []string +} + +// LabelMessages labels the given message IDs with the given label. +// The requests are performed paged; this can eventually be done in parallel. +func (c *Client) LabelMessages(ids []string, label string) (err error) { + for len(ids) > messageIDPageSize { + var requestIDs []string + requestIDs, ids = ids[:messageIDPageSize], ids[messageIDPageSize:] + if err = c.labelMessages(requestIDs, label); err != nil { + return + } + } + + return c.labelMessages(ids, label) +} + +func (c *Client) labelMessages(ids []string, label string) (err error) { + labelReq := &LabelMessagesReq{LabelID: label, IDs: ids} + req, err := NewJSONRequest("PUT", "/messages/label", labelReq) + if err != nil { + return + } + + var res MessagesActionRes + if err = c.DoJSON(req, &res); err != nil { + return + } + + err = res.Err() + return +} + +// UnlabelMessages removes the given label from the given message IDs. +// The requests are performed paged; this can eventually be done in parallel. +func (c *Client) UnlabelMessages(ids []string, label string) (err error) { + for len(ids) > messageIDPageSize { + var requestIDs []string + requestIDs, ids = ids[:messageIDPageSize], ids[messageIDPageSize:] + if err = c.unlabelMessages(requestIDs, label); err != nil { + return + } + } + + return c.unlabelMessages(ids, label) +} + +func (c *Client) unlabelMessages(ids []string, label string) (err error) { + labelReq := &LabelMessagesReq{LabelID: label, IDs: ids} + req, err := NewJSONRequest("PUT", "/messages/unlabel", labelReq) + if err != nil { + return + } + + var res MessagesActionRes + if err = c.DoJSON(req, &res); err != nil { + return + } + + err = res.Err() + return +} + +func (c *Client) EmptyFolder(labelID, addressID string) (err error) { + if labelID == "" { + return errors.New("pmapi: labelID parameter is empty string") + } + reqURL := "/messages/empty?LabelID=" + labelID + if addressID != "" { + reqURL += ("&AddressID=" + addressID) + } + + req, err := NewRequest("DELETE", reqURL, nil) + + if err != nil { + return + } + + var res Res + if err = c.DoJSON(req, &res); err != nil { + return + } + + err = res.Err() + return +} diff --git a/pkg/pmapi/messages_test.go b/pkg/pmapi/messages_test.go new file mode 100644 index 00000000..e7772ecc --- /dev/null +++ b/pkg/pmapi/messages_test.go @@ -0,0 +1,223 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "fmt" + "net/http" + "strings" + "testing" + + pmcrypto "github.com/ProtonMail/gopenpgp/crypto" + "github.com/stretchr/testify/assert" +) + +const testMessageCleartext = `
    jeej saas

    Sent from ProtonMail, encrypted email based in Switzerland.

    ` +const testMessageCleartextLegacy = `
    flkasjfkjasdklfjasd
    fasd
    jfasjdfjasd
    fj
    asdfj
    sadjf
    sadjf
    asjdf
    jasd
    fj
    asdjf
    asdjfsad
    fasdlkfjasdjfkljsadfljsdfjsdljflkdsjfkljsdlkfjsdlk
    jasfd
    jsd
    jf
    sdjfjsdf

    djfskjsladf
    asd
    fja
    sdjfajsf
    jas
    fas
    fj
    afj
    ajf
    af
    asdfasdfasd
    Sent from ProtonMail, encrypted email based in Switzerland.
    dshfljsadfasdf
    as
    df
    asd
    fasd
    f
    asd
    fasdflasdklfjsadlkjf
    asd
    fasdlkfjasdlkfjklasdjflkasjdflaslkfasdfjlasjflkasflksdjflkjasdf
    asdflkasdjflajsfljaslkflasf
    asdfkas
    dfjas
    djf
    asjf
    asj
    faj
    f
    afj
    sdjaf
    jas
    sdfj
    ajf
    aj
    ajsdafafdaaf
    a
    f
    lasl;ga
    sags
    ad
    gags
    g
    ga
    a
    gg
    a
    ag
    ag
    agga.g.ga,ag.ag./ga

    dsga
    sg

    gasga\g\g\g\g\g\n\y\t\r\\r\r\\n\n\n\


    sd
    asdf
    asdf
    dsa
    fasd
    f
    ` + +const testMessageEncrypted = `-----BEGIN PGP MESSAGE----- +Version: OpenPGP.js v1.2.0 +Comment: http://openpgpjs.org + +wcBMA0fcZ7XLgmf2AQf+JPulpEOWwmY/Sfze8rBpYvrO2cebSSkjCgapFfXG +CI4PA+rb+WGkn9uBJf3FgEEg76c2ZqGh9zXTyrdHyFLm8ekarvxzgLpvcei/ +p18IzcxsWnaM+1uknL4bKUtK3298gIl6xrfc4eVEA8tqUPUkSLSGk7uggjhj +zEYR4zIgMa0c6sMVcZ1Idvy9gGsTIvvcZJ4h1lKVUl8gba+qr1D76RaAf5xS +SBT74q9HhgfEMZwk6hXAp4MYY5h+lIsuhFu5kQ9fhZKU0PWS7ljddv854ZxS +9gHKPBerv4NBjkkCLp9xa2QNjDnu1fNlzlJpfCavp6wDdC83GiT61VRHPE4s +J9LASwFwgOrPmB8Mi867AQM0dddbj4Qe5ghlUcF1XnybkwfHqvQA1QT50d5n +ddFyxwIjvI/Nsn8MTCSnmrWCrjQ7v8JC73NyGxO5k6ZlUnc6BQVie78QJo5a +ftzl5b6nwlCYuXI8R6N/t5MXzrC5GwR8nvjH6kgbUVTLL1hO2Sbgyq5bBKLW +jjylTsZDHUGi4OX7q7eet5/RhKusWdvR0cHEaZAVD6BhTNN0mFBJ5bM1SINI +9gxJVqKJe7j4nJP4PGZBJrokZihhiBS/WEbJdvS54frYajGKjMavB3VhFP6k +qi5aiqGJKOJOV/G8yIwtdtxac3UL34eWo69U39Zx2mNfSXCzSjuafCr1nmAS +4g== +=Uw3B +-----END PGP MESSAGE----- +` + +const testMessageEncryptedLegacy = `---BEGIN ENCRYPTED MESSAGE---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---END ENCRYPTED MESSAGE---||---BEGIN ENCRYPTED RANDOM KEY--------BEGIN PGP MESSAGE----- +Version: OpenPGP.js v0.9.0 +Comment: http://openpgpjs.org + +wcBMA2tjJVxNCRhtAQf/YzkQoUqaqa3NJ/c1apIF/dsl7yJ4GdVrC3/w7lxE +2CO5ioQD4s6QMWP2Y9dOdVl2INwz8eXOds9NS+1nMs4SoMbrpJnAjx8Cthti +1Z/8eWMU023LYahds8BYM0T435K/2tTB5GTA4uTl2y8Xzz2PbptQ4PrUDaII ++egeQQyPA0yuoRDwpaeTiaBYOSa06YYuK5Agr0buQAxRIMCxI2o+fucjoabv +FsQHKGu20U5GlJroSIyIVVkaH3evhNti/AnYX1HuokcGEQNsF5vo4SjWcH23 +2P86EIV+w5lUWC1FN9vZCyvbvyuqLHQMtqKVn4GBOkIc3bYQ0jru3a0FG4Cx +bNJ0ASps2+p3Vxe0d+so2iFV92ByQ+0skyCUwCNUlwOV5V5f2fy1ImXk4mXI +cO/bcbqRxx3pG9gkPIh43FoQktTT+tsJ5vS53qfaLGdhCYfkrWjsKu+2P9Xg ++Cr8clh6NTblhfkoAS1gzjA3XgsgEFrtP+OGqwg= +=c5WU +-----END PGP MESSAGE----- +---END ENCRYPTED RANDOM KEY--- +` + +const testMessageSigned = `-----BEGIN PGP MESSAGE----- +Version: OpenPGP.js v4.5.3 +Comment: https://openpgpjs.org + +wcBMA0fcZ7XLgmf2AQgAgnHOlcAwVu2AnVfi2fIQHSkTQ0OFnZMJMRR3MJ1q +HtUW8jkSLcurL0Sn/tBFLIIR4YT2tQMzV7cvZzZyBEuZM4OYnDp8xSmoszPh +Gc/nvYG0A0pmKAQkL27v05Dul8oUWA0APT51urghH2Pzm7NdOMtTKIE4LQjS +mBfQ6Cf14uKV0xGS9v2dSFjFxxXEEpMQ+k60NCKRYClN2LVVxf3OKXbuugds +m2GUGn3CuFsiabosIUv4EcdE3aD9HbNo+PIWLJWRJIYJSc5+FWcbwXuIIFgC +XX1s7OV53ceZJnhjCmDE0N2ZOLLAYWED2zRvUa+CAqG+hZgc/3Ia+UmJUVuZ +BNLAugFuRsOVgh3olUIz0vazHhyGG0XIsNqmRm0U9SIfhWkPPHBmU6Xht6Qw +EvLbBfKTYHxX01yQUNgIv4S/TULeQuUjZQfsNYNXXGepS+jiCoIdEgUwpvre +OMFGsypwQXVCFYO/GQdYanMQRTckEexyBY4hGYVrevDM1yG/zGJIdbfI2L+1 +1cz76jI8PtzL+S0zcVkevLcjjsHm2Je959uSida9jara7Bymr0y56UdoXoWX +4vZ0kQNo58eEEV0zg7dit4lDvwcuSZMW6K//xNtRQ4QX7/EDtlcYqBJXPwJY +eQSBVeYbeUbZ+PHJdu5gbI85BJNE2dKcS1bdOhEU2lPLYpvmMpPdot9TwnJb +dN3l8yDyhScGvTIZqlxhU7HCM9VHAS0bDqCUoO8EruztUSgjMI+gKC9+xdVU +yrkF7K23UNLWflROMv4cp0LDRB57619Y2w5lY/MG5bS0jSfMWBwnJG2AF28c +2tYKnHw6rpZXvXnlDmEDT8suTzuTGA== +=Sir8 +-----END PGP MESSAGE----- +` + +const testMessageSigner = `-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: OpenPGP.js v0.7.1 +Comment: http://openpgpjs.org + +xsBNBFSI0BMBB/9td6B5RDzVSFTlFzYOS4JxIb5agtNW1rbA4FeLoC47bGLR +8E42IA6aKcO4H0vOZ1lFms0URiKk1DjCMXn3AUErbxqiV5IATRZLwliH6vwy +PI6j5rtGF8dyxYfwmLtoUNkDcPdcFEb4NCdowsN7e8tKU0bcpouZcQhAqawC +9nEdaG/gS5w+2k4hZX2lOKS1EF5SvP48UadlspEK2PLAIp5wB9XsFS9ey2wu +elzkSfDh7KUAlteqFGSMqIgYH62/gaKm+TcckfZeyiMHWFw6sfrcFQ3QOZPq +ahWt0Rn9XM5xBAxx5vW0oceuQ1vpvdfFlM5ix4gn/9w6MhmStaCee8/fABEB +AAHNBlVzZXJJRMLAcgQQAQgAJgUCVIjQHQYLCQgHAwIJEASDR1Fk7GNTBBUI +AgoDFgIBAhsDAh4BAADmhAf/Yt0mCfWqQ25NNGUN14pKKgnPm68zwj1SmMGa +pU7+7ItRpoFNaDwV5QYiQSLC1SvSb1ZeKoY928GPKfqYyJlBpTPL9zC1OHQj +9+2yYauHjYW9JWQM7hst2S2LBcdiQPOs3ybWPaO9yaccV4thxKOCPvyClaS5 +b9T4Iv9GEVZQIUvArkwI8hyzIi6skRgxflGheq1O+S1W4Gzt2VtYvo8g8r6W +GzAGMw2nrs2h0+vUr+dLDgIbFCTc5QU99d5jE/e5Hw8iqBxv9tqB1hVATf8T +wC8aU5MTtxtabOiBgG0PsBs6oIwjFqEjpOIza2/AflPZfo7stp6IiwbwvTHo +1NlHoM7ATQRUiNAdAQf/eOLJYxX4lUQUzrNQgASDNE8gJPj7ywcGzySyqr0Y +5rbG57EjtKMIgZrpzJRpSCuRbBjfsltqJ5Q9TBAbPO+oR3rue0LqPKMnmr/q +KsHswBJRfsb/dbktUNmv/f7R9IVyOuvyP6RgdGeloxdGNeWiZSA6AZYI+WGc +xaOvVDPz8thtnML4G4MUhXxxNZ7JzQ0Lfz6mN8CCkblIP5xpcJsyRU7lUsGD +EJGZX0JH/I8bRVN1Xu08uFinIkZyiXRJ5ZGgF3Dns6VbIWmbttY54tBELtk+ +5g9pNSl9qiYwiCdwuZrA//NmD3xlZIN8sG4eM7ZUibZ23vEq+bUt1++6Mpba +GQARAQABwsBfBBgBCAATBQJUiNAfCRAEg0dRZOxjUwIbDAAAlpMH/085qZdO +mGRAlbvViUNhF2rtHvCletC48WHGO1ueSh9VTxalkP21YAYLJ4JgJzArJ7tH +lEeiKiHm8YU9KhLe11Yv/o3AiKIAQjJiQluvk+mWdMcddB4fBjL6ttMTRAXe +gHnjtMoamHbSZdeUTUadv05Fl6ivWtpXlODG4V02YvDiGBUbDosdGXEqDtpT +g6MYlj3QMvUiUNQvt7YGMJS8A9iQ9qBNzErgRW8L6CON2RmpQ/wgwP5nwUHz +JjY51d82Vj8bZeI8LdsX41SPoUhyC7kmNYpw9ZRy7NlrCt8dBIOB4/BKEJ2G +ClW54lp9eeOfYTsdTSbn9VaSO0E6m2/Q4Tk= +=WFtr +-----END PGP PUBLIC KEY BLOCK-----` + +func TestMessage_IsBodyEncrypted(t *testing.T) { + msg := &Message{Body: testMessageEncrypted} + Assert(t, msg.IsBodyEncrypted(), "the body should be encrypted") + + msg.Body = testMessageCleartext + Assert(t, !msg.IsBodyEncrypted(), "the body should not be encrypted") +} + +func TestMessage_Decrypt(t *testing.T) { + msg := &Message{Body: testMessageEncrypted} + err := msg.Decrypt(testPrivateKeyRing) + Ok(t, err) + Equals(t, testMessageCleartext, msg.Body) +} + +func TestMessage_Decrypt_Legacy(t *testing.T) { + testPrivateKeyLegacy := readTestFile("testPrivateKeyLegacy", false) + testPrivateKeyRingLegacy, err := pmcrypto.ReadArmoredKeyRing(strings.NewReader(testPrivateKeyLegacy)) + Ok(t, err) + + Ok(t, testPrivateKeyRingLegacy.Unlock([]byte(testMailboxPasswordLegacy))) + + msg := &Message{Body: testMessageEncryptedLegacy} + + err = msg.Decrypt(testPrivateKeyRingLegacy) + Ok(t, err) + + Equals(t, testMessageCleartextLegacy, msg.Body) +} + +func TestMessage_Decrypt_signed(t *testing.T) { + msg := &Message{Body: testMessageSigned} + err := msg.Decrypt(testPrivateKeyRing) + Ok(t, err) + Equals(t, testMessageCleartext, msg.Body) +} + +func TestMessage_Encrypt(t *testing.T) { + signer, err := pmcrypto.ReadArmoredKeyRing(strings.NewReader(testMessageSigner)) + Ok(t, err) + + msg := &Message{Body: testMessageCleartext} + Ok(t, msg.Encrypt(testPrivateKeyRing, testPrivateKeyRing)) + + err = msg.Decrypt(testPrivateKeyRing) + Ok(t, err) + + Equals(t, testMessageCleartext, msg.Body) + Equals(t, testIdentity, signer.Identities()[0]) +} + +func routeLabelMessages(tb testing.TB, w http.ResponseWriter, r *http.Request) string { + Ok(tb, checkMethodAndPath(r, "PUT", "/messages/label")) + + return "messages/label/put_response.json" +} + +func TestMessage_LabelMessages_NoPaging(t *testing.T) { + // This should be only enough IDs to produce one page. + testIDs := []string{} + for i := 0; i < messageIDPageSize-1; i++ { + testIDs = append(testIDs, fmt.Sprintf("%v", i)) + } + + // There should be enough IDs to produce just one page so the endpoint should be called once. + finish, c := newTestServerCallbacks(t, + routeLabelMessages, + ) + defer finish() + + c.uid = testUID + c.accessToken = testAccessToken + + assert.NoError(t, c.LabelMessages(testIDs, "mylabel")) +} + +func TestMessage_LabelMessages_Paging(t *testing.T) { + // This should be enough IDs to produce three pages. + testIDs := []string{} + for i := 0; i < 3*messageIDPageSize; i++ { + testIDs = append(testIDs, fmt.Sprintf("%v", i)) + } + + // There should be enough IDs to produce three pages so the endpoint should be called three times. + finish, c := newTestServerCallbacks(t, + routeLabelMessages, + routeLabelMessages, + routeLabelMessages, + ) + defer finish() + + c.uid = testUID + c.accessToken = testAccessToken + + assert.NoError(t, c.LabelMessages(testIDs, "mylabel")) +} diff --git a/pkg/pmapi/metrics.go b/pkg/pmapi/metrics.go new file mode 100644 index 00000000..b0603d86 --- /dev/null +++ b/pkg/pmapi/metrics.go @@ -0,0 +1,43 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "net/url" +) + +// SendSimpleMetric makes a simple GET request to send a simple metrics report. +func (c *Client) SendSimpleMetric(category, action, label string) (err error) { + v := url.Values{} + v.Set("Category", category) + v.Set("Action", action) + v.Set("Label", label) + + req, err := NewRequest("GET", "/metrics?"+v.Encode(), nil) + if err != nil { + return + } + + var res Res + if err = c.DoJSON(req, &res); err != nil { + return + } + + err = res.Err() + return +} diff --git a/pkg/pmapi/metrics_test.go b/pkg/pmapi/metrics_test.go new file mode 100644 index 00000000..c43b4dce --- /dev/null +++ b/pkg/pmapi/metrics_test.go @@ -0,0 +1,43 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "fmt" + "net/http" + "testing" +) + +const testSendSimpleMetricsBody = `{ + "Code": 1000 +} +` + +func TestClient_SendSimpleMetric(t *testing.T) { + s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Ok(t, checkMethodAndPath(r, "GET", "/metrics?Action=some_action&Category=some_category&Label=some_label")) + + fmt.Fprint(w, testSendSimpleMetricsBody) + })) + defer s.Close() + + err := c.SendSimpleMetric("some_category", "some_action", "some_label") + if err != nil { + t.Fatal("Expected no error while sending simple metric, got:", err) + } +} diff --git a/pkg/pmapi/passwords.go b/pkg/pmapi/passwords.go new file mode 100644 index 00000000..73410091 --- /dev/null +++ b/pkg/pmapi/passwords.go @@ -0,0 +1,47 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "encoding/base64" + "errors" + + "github.com/jameskeane/bcrypt" +) + +func HashMailboxPassword(password, keySalt string) (hashedPassword string, err error) { + if keySalt == "" { + hashedPassword = password + return + } + decodedSalt, err := base64.StdEncoding.DecodeString(keySalt) + if err != nil { + return + } + encodedSalt := base64.NewEncoding("./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789").WithPadding(base64.NoPadding).EncodeToString(decodedSalt) + hashResult, err := bcrypt.Hash(password, "$2y$10$"+encodedSalt) + if err != nil { + return + } + if len(hashResult) != 60 { + err = errors.New("pmapi: invalid mailbox password hash") + return + } + hashedPassword = hashResult[len(hashResult)-31:] + return +} diff --git a/pkg/pmapi/pmapi_test.go b/pkg/pmapi/pmapi_test.go new file mode 100644 index 00000000..17248cb9 --- /dev/null +++ b/pkg/pmapi/pmapi_test.go @@ -0,0 +1,62 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "io/ioutil" + "strings" + + pmcrypto "github.com/ProtonMail/gopenpgp/crypto" +) + +const testMailboxPassword = "apple" +const testMailboxPasswordLegacy = "123" + +var ( + testPrivateKeyRing *pmcrypto.KeyRing + testPublicKeyRing *pmcrypto.KeyRing +) + +func init() { + testPrivateKey := readTestFile("testPrivateKey", false) + testPublicKey := readTestFile("testPublicKey", false) + + var err error + if testPrivateKeyRing, err = pmcrypto.ReadArmoredKeyRing(strings.NewReader(testPrivateKey)); err != nil { + panic(err) + } + + if testPublicKeyRing, err = pmcrypto.ReadArmoredKeyRing(strings.NewReader(testPublicKey)); err != nil { + panic(err) + } + + if err := testPrivateKeyRing.Unlock([]byte(testMailboxPassword)); err != nil { + panic(err) + } +} + +func readTestFile(name string, trimNewlines bool) string { // nolint[unparam] + data, err := ioutil.ReadFile("testdata/" + name) + if err != nil { + panic(err) + } + if trimNewlines { + return strings.TrimRight(string(data), "\n") + } + return string(data) +} diff --git a/pkg/pmapi/proxy.go b/pkg/pmapi/proxy.go new file mode 100644 index 00000000..2ba711be --- /dev/null +++ b/pkg/pmapi/proxy.go @@ -0,0 +1,304 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "crypto/tls" + "encoding/base64" + "strings" + "sync" + "time" + + "github.com/go-resty/resty/v2" + "github.com/miekg/dns" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" +) + +const ( + proxyRevertTime = 24 * time.Hour + proxySearchTimeout = 30 * time.Second + proxyQueryTimeout = 10 * time.Second + proxyLookupWait = 5 * time.Second + proxyQuery = "dMFYGSLTQOJXXI33ONVQWS3BOMNUA.protonpro.xyz" +) + +var dohProviders = []string{ //nolint[gochecknoglobals] + "https://dns11.quad9.net/dns-query", + "https://dns.google/dns-query", +} + +// globalAllowDoH controls whether or not to enable use of DoH/Proxy in pmapi. +var globalAllowDoH = false // nolint[golint] + +// globalProxyMutex allows threadsafe modification of proxy state. +var globalProxyMutex = sync.RWMutex{} // nolint[golint] + +// globalOriginalURL backs up the original API url so it can be restored later. +var globalOriginalURL = RootURL // nolint[golint] + +// globalIsDoHAllowed returns whether or not to use DoH. +func globalIsDoHAllowed() bool { // nolint[golint] + globalProxyMutex.RLock() + defer globalProxyMutex.RUnlock() + + return globalAllowDoH +} + +// GlobalAllowDoH enables DoH. +func GlobalAllowDoH() { // nolint[golint] + globalProxyMutex.Lock() + defer globalProxyMutex.Unlock() + + globalAllowDoH = true +} + +// GlobalDisallowDoH disables DoH and sets the RootURL back to what it was. +func GlobalDisallowDoH() { // nolint[golint] + globalProxyMutex.Lock() + defer globalProxyMutex.Unlock() + + globalAllowDoH = false + RootURL = globalOriginalURL +} + +// globalSetRootURL sets the global RootURL. +func globalSetRootURL(url string) { // nolint[golint] + globalProxyMutex.Lock() + defer globalProxyMutex.Unlock() + + RootURL = url +} + +// GlobalGetRootURL returns the global RootURL. +func GlobalGetRootURL() (url string) { // nolint[golint] + globalProxyMutex.RLock() + defer globalProxyMutex.RUnlock() + + return RootURL +} + +// isProxyEnabled returns whether or not we are currently using a proxy. +func isProxyEnabled() bool { // nolint[golint] + return globalOriginalURL != GlobalGetRootURL() +} + +// proxyManager manages known proxies. +type proxyManager struct { + // dohLookup is used to look up the given query at the given DoH provider, returning the TXT records> + dohLookup func(query, provider string) (urls []string, err error) + + providers []string // List of known doh providers. + query string // The query string used to find proxies. + proxyCache []string // All known proxies, cached in case DoH providers are unreachable. + + useDuration time.Duration // How much time to use the proxy before returning to the original API. + findTimeout, lookupTimeout time.Duration // Timeouts for DNS query and proxy search. + + lastLookup time.Time // The time at which we last attempted to find a proxy. +} + +// newProxyManager creates a new proxyManager that queries the given DoH providers +// to retrieve DNS records for the given query string. +func newProxyManager(providers []string, query string) (p *proxyManager) { // nolint[unparam] + p = &proxyManager{ + providers: providers, + query: query, + useDuration: proxyRevertTime, + findTimeout: proxySearchTimeout, + lookupTimeout: proxyQueryTimeout, + } + + // Use the default DNS lookup method; this can be overridden if necessary. + p.dohLookup = p.defaultDoHLookup + + return +} + +// findProxy returns a new proxy domain which is not equal to the current RootURL. +// It returns an error if the process takes longer than ProxySearchTime. +func (p *proxyManager) findProxy() (proxy string, err error) { + if time.Now().Before(p.lastLookup.Add(proxyLookupWait)) { + return "", errors.New("not looking for a proxy, too soon") + } + + p.lastLookup = time.Now() + + proxyResult := make(chan string) + errResult := make(chan error) + go func() { + if err = p.refreshProxyCache(); err != nil { + logrus.WithError(err).Warn("Failed to refresh proxy cache, cache may be out of date") + } + + for _, proxy := range p.proxyCache { + if proxy != stripProtocol(GlobalGetRootURL()) && p.canReach(proxy) { + proxyResult <- proxy + return + } + } + + errResult <- errors.New("no proxy available") + }() + + select { + case <-time.After(p.findTimeout): + logrus.Error("Timed out finding a proxy server") + return "", errors.New("timed out finding a proxy") + + case proxy = <-proxyResult: + logrus.WithField("proxy", proxy).Info("Found proxy server") + return + + case err = <-errResult: + logrus.WithError(err).Error("Failed to find available proxy server") + return + } +} + +// useProxy sets the proxy server to use. It returns to the original RootURL after 24 hours. +func (p *proxyManager) useProxy(proxy string) { + if !isProxyEnabled() { + p.disableProxyAfter(p.useDuration) + } + + globalSetRootURL(https(proxy)) +} + +// disableProxyAfter disables the proxy after the given amount of time. +func (p *proxyManager) disableProxyAfter(d time.Duration) { + go func() { + <-time.After(d) + globalSetRootURL(globalOriginalURL) + }() +} + +// refreshProxyCache loads the latest proxies from the known providers. +func (p *proxyManager) refreshProxyCache() error { + logrus.Info("Refreshing proxy cache") + + for _, provider := range p.providers { + if proxies, err := p.dohLookup(p.query, provider); err == nil { + p.proxyCache = proxies + + // We also want to allow bridge to switch back to the standard API at any time. + p.proxyCache = append(p.proxyCache, globalOriginalURL) + + logrus.WithField("proxies", proxies).Info("Available proxies") + + return nil + } + } + + return errors.New("lookup failed with all DoH providers") +} + +// canReach returns whether we can reach the given url. +// NOTE: we skip cert verification to stop it complaining that cert name doesn't match hostname. +func (p *proxyManager) canReach(url string) bool { + pinger := resty.New(). + SetHostURL(https(url)). + SetTimeout(p.lookupTimeout). + SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true}) // nolint[gosec] + + if _, err := pinger.R().Get("/tests/ping"); err != nil { + return false + } + + return true +} + +// defaultDoHLookup is the default implementation of the proxy manager's DoH lookup. +// It looks up DNS TXT records for the given query URL using the given DoH provider. +// It returns a list of all found TXT records. +// If the whole process takes more than ProxyQueryTime then an error is returned. +func (p *proxyManager) defaultDoHLookup(query, dohProvider string) (data []string, err error) { + dataResult := make(chan []string) + errResult := make(chan error) + go func() { + // Build new DNS request in RFC1035 format. + dnsRequest := new(dns.Msg).SetQuestion(dns.Fqdn(query), dns.TypeTXT) + + // Pack the DNS request message into wire format. + rawRequest, err := dnsRequest.Pack() + if err != nil { + errResult <- errors.Wrap(err, "failed to pack DNS request") + return + } + + // Encode wire-format DNS request message as base64url (RFC4648) without padding chars. + encodedRequest := base64.RawURLEncoding.EncodeToString(rawRequest) + + // Make DoH request to the given DoH provider. + rawResponse, err := resty.New().R().SetQueryParam("dns", encodedRequest).Get(dohProvider) + if err != nil { + errResult <- errors.Wrap(err, "failed to make DoH request") + return + } + + // Unpack the DNS response. + dnsResponse := new(dns.Msg) + if err = dnsResponse.Unpack(rawResponse.Body()); err != nil { + errResult <- errors.Wrap(err, "failed to unpack DNS response") + return + } + + // Pick out the TXT answers. + for _, answer := range dnsResponse.Answer { + if t, ok := answer.(*dns.TXT); ok { + data = append(data, t.Txt...) + } + } + + dataResult <- data + }() + + select { + case <-time.After(p.lookupTimeout): + logrus.WithField("provider", dohProvider).Error("Timed out querying DNS records") + return []string{}, errors.New("timed out querying DNS records") + + case data = <-dataResult: + logrus.WithField("data", data).Info("Received TXT records") + return + + case err = <-errResult: + logrus.WithField("provider", dohProvider).WithError(err).Error("Failed to query DNS records") + return + } +} + +func stripProtocol(url string) string { + if strings.HasPrefix(url, "https://") { + return strings.TrimPrefix(url, "https://") + } + + if strings.HasPrefix(url, "http://") { + return strings.TrimPrefix(url, "http://") + } + + return url +} + +func https(url string) string { + if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") { + url = "https://" + url + } + + return url +} diff --git a/pkg/pmapi/proxy_test.go b/pkg/pmapi/proxy_test.go new file mode 100644 index 00000000..15cf3257 --- /dev/null +++ b/pkg/pmapi/proxy_test.go @@ -0,0 +1,304 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +const ( + TestDoHQuery = "dMFYGSLTQOJXXI33ONVQWS3BOMNUA.protonpro.xyz" + TestQuad9Provider = "https://dns11.quad9.net/dns-query" + TestGoogleProvider = "https://dns.google/dns-query" +) + +func TestProxyManager_FindProxy(t *testing.T) { + blockAPI() + defer unblockAPI() + + proxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer proxy.Close() + + p := newProxyManager([]string{"not used"}, "not used") + p.dohLookup = func(q, p string) ([]string, error) { return []string{proxy.URL}, nil } + + url, err := p.findProxy() + require.NoError(t, err) + require.Equal(t, proxy.URL, url) +} + +func TestProxyManager_FindProxy_ChooseReachableProxy(t *testing.T) { + blockAPI() + defer unblockAPI() + + badProxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + goodProxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + + // Close the bad proxy first so it isn't reachable; we should then choose the good proxy. + badProxy.Close() + defer goodProxy.Close() + + p := newProxyManager([]string{"not used"}, "not used") + p.dohLookup = func(q, p string) ([]string, error) { return []string{badProxy.URL, goodProxy.URL}, nil } + + url, err := p.findProxy() + require.NoError(t, err) + require.Equal(t, goodProxy.URL, url) +} + +func TestProxyManager_FindProxy_FailIfNoneReachable(t *testing.T) { + blockAPI() + defer unblockAPI() + + badProxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + anotherBadProxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + + // Close the proxies to simulate them not being reachable. + badProxy.Close() + anotherBadProxy.Close() + + p := newProxyManager([]string{"not used"}, "not used") + p.dohLookup = func(q, p string) ([]string, error) { return []string{badProxy.URL, anotherBadProxy.URL}, nil } + + _, err := p.findProxy() + require.Error(t, err) +} + +func TestProxyManager_FindProxy_LookupTimeout(t *testing.T) { + blockAPI() + defer unblockAPI() + + proxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer proxy.Close() + + p := newProxyManager([]string{"not used"}, "not used") + p.lookupTimeout = time.Second + p.dohLookup = func(q, p string) ([]string, error) { time.Sleep(2 * time.Second); return nil, nil } + + // The findProxy should fail because lookup takes 2 seconds but we only allow 1 second. + _, err := p.findProxy() + require.Error(t, err) +} + +func TestProxyManager_FindProxy_FindTimeout(t *testing.T) { + blockAPI() + defer unblockAPI() + + slowProxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(2 * time.Second) + })) + defer slowProxy.Close() + + p := newProxyManager([]string{"not used"}, "not used") + p.findTimeout = time.Second + p.dohLookup = func(q, p string) ([]string, error) { return []string{slowProxy.URL}, nil } + + // The findProxy should fail because lookup takes 2 seconds but we only allow 1 second. + _, err := p.findProxy() + require.Error(t, err) +} + +func TestProxyManager_UseProxy(t *testing.T) { + blockAPI() + defer unblockAPI() + + proxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer proxy.Close() + + p := newProxyManager([]string{"not used"}, "not used") + p.dohLookup = func(q, p string) ([]string, error) { return []string{proxy.URL}, nil } + + url, err := p.findProxy() + require.NoError(t, err) + + p.useProxy(url) + require.Equal(t, proxy.URL, GlobalGetRootURL()) +} + +func TestProxyManager_UseProxy_MultipleTimes(t *testing.T) { + blockAPI() + defer unblockAPI() + + proxy1 := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer proxy1.Close() + proxy2 := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer proxy2.Close() + proxy3 := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer proxy3.Close() + + p := newProxyManager([]string{"not used"}, "not used") + + p.dohLookup = func(q, p string) ([]string, error) { return []string{proxy1.URL}, nil } + url, err := p.findProxy() + require.NoError(t, err) + p.useProxy(url) + require.Equal(t, proxy1.URL, GlobalGetRootURL()) + + // Have to wait so as to not get rejected. + time.Sleep(proxyLookupWait) + + p.dohLookup = func(q, p string) ([]string, error) { return []string{proxy2.URL}, nil } + url, err = p.findProxy() + require.NoError(t, err) + p.useProxy(url) + require.Equal(t, proxy2.URL, GlobalGetRootURL()) + + // Have to wait so as to not get rejected. + time.Sleep(proxyLookupWait) + + p.dohLookup = func(q, p string) ([]string, error) { return []string{proxy3.URL}, nil } + url, err = p.findProxy() + require.NoError(t, err) + p.useProxy(url) + require.Equal(t, proxy3.URL, GlobalGetRootURL()) +} + +func TestProxyManager_UseProxy_RevertAfterTime(t *testing.T) { + blockAPI() + defer unblockAPI() + + proxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer proxy.Close() + + p := newProxyManager([]string{"not used"}, "not used") + p.useDuration = time.Second + p.dohLookup = func(q, p string) ([]string, error) { return []string{proxy.URL}, nil } + + url, err := p.findProxy() + require.NoError(t, err) + require.Equal(t, proxy.URL, url) + + p.useProxy(url) + require.Equal(t, proxy.URL, GlobalGetRootURL()) + + time.Sleep(2 * time.Second) + require.Equal(t, globalOriginalURL, GlobalGetRootURL()) +} + +func TestProxyManager_UseProxy_RevertIfProxyStopsWorkingAndOriginalAPIIsReachable(t *testing.T) { + // Don't block the API here because we want it to be working so the test can find it. + defer unblockAPI() + + proxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer proxy.Close() + + p := newProxyManager([]string{"not used"}, "not used") + p.dohLookup = func(q, p string) ([]string, error) { return []string{proxy.URL}, nil } + + url, err := p.findProxy() + require.NoError(t, err) + require.Equal(t, proxy.URL, url) + + p.useProxy(url) + require.Equal(t, proxy.URL, GlobalGetRootURL()) + + // Simulate that the proxy stops working. + proxy.Close() + time.Sleep(proxyLookupWait) + + // We should now find the original API URL if it is working again. + url, err = p.findProxy() + require.NoError(t, err) + require.Equal(t, globalOriginalURL, url) + + p.useProxy(url) + require.Equal(t, globalOriginalURL, GlobalGetRootURL()) +} + +func TestProxyManager_UseProxy_FindSecondAlternativeIfFirstFailsAndAPIIsStillBlocked(t *testing.T) { + blockAPI() + defer unblockAPI() + + proxy1 := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer proxy1.Close() + proxy2 := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer proxy2.Close() + + p := newProxyManager([]string{"not used"}, "not used") + p.dohLookup = func(q, p string) ([]string, error) { return []string{proxy1.URL, proxy2.URL}, nil } + + // Find a proxy. + url, err := p.findProxy() + require.NoError(t, err) + p.useProxy(url) + require.Equal(t, proxy1.URL, GlobalGetRootURL()) + + // Have to wait so as to not get rejected. + time.Sleep(proxyLookupWait) + + // The proxy stops working and the protonmail API is still blocked. + proxy1.Close() + + // Should switch to the second proxy because both the first proxy and the protonmail API are blocked. + url, err = p.findProxy() + require.NoError(t, err) + p.useProxy(url) + require.Equal(t, proxy2.URL, GlobalGetRootURL()) +} + +func TestProxyManager_DoHLookup_Quad9(t *testing.T) { + p := newProxyManager([]string{TestQuad9Provider, TestGoogleProvider}, TestDoHQuery) + + records, err := p.dohLookup(TestDoHQuery, TestQuad9Provider) + require.NoError(t, err) + require.NotEmpty(t, records) +} + +func TestProxyManager_DoHLookup_Google(t *testing.T) { + p := newProxyManager([]string{TestQuad9Provider, TestGoogleProvider}, TestDoHQuery) + + records, err := p.dohLookup(TestDoHQuery, TestGoogleProvider) + require.NoError(t, err) + require.NotEmpty(t, records) +} + +func TestProxyManager_DoHLookup_FindProxy(t *testing.T) { + p := newProxyManager([]string{TestQuad9Provider, TestGoogleProvider}, TestDoHQuery) + + url, err := p.findProxy() + require.NoError(t, err) + require.NotEmpty(t, url) +} + +func TestProxyManager_DoHLookup_FindProxyFirstProviderUnreachable(t *testing.T) { + p := newProxyManager([]string{"https://unreachable", TestGoogleProvider}, TestDoHQuery) + + url, err := p.findProxy() + require.NoError(t, err) + require.NotEmpty(t, url) +} + +// testAPIURLBackup is used to hold the globalOriginalURL because we clear it for test purposes and need to restore it. +var testAPIURLBackup = globalOriginalURL + +// blockAPI prevents tests from reaching the standard API, forcing them to find a proxy. +func blockAPI() { + globalSetRootURL("") + globalOriginalURL = "" +} + +// unblockAPI allow tests to reach the standard API again. +func unblockAPI() { + globalOriginalURL = testAPIURLBackup + globalSetRootURL(globalOriginalURL) +} diff --git a/pkg/pmapi/req.go b/pkg/pmapi/req.go new file mode 100644 index 00000000..66a989b7 --- /dev/null +++ b/pkg/pmapi/req.go @@ -0,0 +1,90 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "bytes" + "encoding/json" + "io" + "mime/multipart" + "net/http" +) + +// NewRequest creates a new request. +func NewRequest(method, path string, body io.Reader) (req *http.Request, err error) { + req, err = http.NewRequest(method, GlobalGetRootURL()+path, body) + if req != nil { + req.Header.Set("User-Agent", CurrentUserAgent) + } + return +} + +// NewJSONRequest create a new JSON request. +func NewJSONRequest(method, path string, body interface{}) (*http.Request, error) { + b, err := json.Marshal(body) + if err != nil { + panic(err) + } + + req, err := NewRequest(method, path, bytes.NewReader(b)) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", "application/json") + + return req, nil +} + +type MultipartWriter struct { + *multipart.Writer + + c io.Closer +} + +func (w *MultipartWriter) Close() error { + if err := w.Writer.Close(); err != nil { + return err + } + return w.c.Close() +} + +// NewMultipartRequest creates a new multipart request. +// +// The multipart request is written as long as it is sent to the API. That means +// that writing the request and sending it MUST be done in parallel. If the +// request fails, subsequent writes to the multipart writer will fail with an +// io.ErrClosedPipe error. +func NewMultipartRequest(method, path string) (req *http.Request, w *MultipartWriter, err error) { + // The pipe will connect the multipart writer and the HTTP request body. + pr, pw := io.Pipe() + + // pw needs to be closed once the multipart writer is closed. + w = &MultipartWriter{ + multipart.NewWriter(pw), + pw, + } + + req, err = NewRequest(method, path, pr) + if err != nil { + return + } + + req.Header.Add("Content-Type", w.FormDataContentType()) + return +} diff --git a/pkg/pmapi/res.go b/pkg/pmapi/res.go new file mode 100644 index 00000000..5f196a34 --- /dev/null +++ b/pkg/pmapi/res.go @@ -0,0 +1,72 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +// Common response codes. +const ( + CodeOk = 1000 +) + +// Res is an API response. +type Res struct { + // The response code is the code from the body JSON. It's still used, + // but preference is to use HTTP status code instead for new changes. + Code int + StatusCode int + + // The error, if there is any. + *ResError +} + +// Err returns error if the response is an error. Otherwise, returns nil. +func (res Res) Err() error { + if res.ResError == nil { + return nil + } + + if res.Code == ForceUpgradeBadAPIVersion || + res.Code == ForceUpgradeInvalidAPI || + res.Code == ForceUpgradeBadAppVersion { + return ErrUpgradeApplication + } + + if res.Code == APIOffline { + return ErrAPINotReachable + } + + return &Error{ + Code: res.Code, + ErrorMessage: res.ResError.Error, + } +} + +type ResError struct { + Error string +} + +// Error is an API error. +type Error struct { + // The error code. + Code int + // The error message. + ErrorMessage string `json:"Error"` +} + +func (err Error) Error() string { + return err.ErrorMessage +} diff --git a/pkg/pmapi/sentry.go b/pkg/pmapi/sentry.go new file mode 100644 index 00000000..82307449 --- /dev/null +++ b/pkg/pmapi/sentry.go @@ -0,0 +1,173 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "fmt" + "regexp" + "runtime" + "runtime/pprof" + "strconv" + "strings" + + "github.com/getsentry/raven-go" +) + +const fileParseError = "[file parse error]" + +var isGoroutine = regexp.MustCompile("^goroutine [[:digit:]]+.*") //nolint[gochecknoglobals] + +// SentryThreads implements standard sentry thread report. +type SentryThreads struct { + Values []Thread `json:"values"` +} + +// Class specifier. +func (s *SentryThreads) Class() string { return "threads" } + +// Thread wraps a single stacktrace. +type Thread struct { + ID int `json:"id"` + Name string `json:"name"` + Crashed bool `json:"crashed"` + Stacktrace *raven.Stacktrace `json:"stacktrace"` +} + +// TraceAllRoutines traces all goroutines and saves them to the current object. +func (s *SentryThreads) TraceAllRoutines() { + s.Values = []Thread{} + goroutines := &strings.Builder{} + _ = pprof.Lookup("goroutine").WriteTo(goroutines, 2) + + thread := Thread{ID: -1} + var frame *raven.StacktraceFrame + for _, v := range strings.Split(goroutines.String(), "\n") { + // Ignore empty lines. + if v == "" { + continue + } + + // New routine. + if isGoroutine.MatchString(v) { + if thread.ID >= 0 { + s.Values = append(s.Values, thread) + } + thread = Thread{ID: thread.ID + 1, Name: v, Crashed: thread.ID == -1, Stacktrace: &raven.Stacktrace{Frames: []*raven.StacktraceFrame{}}} + continue + } + + // New function. + if frame == nil { + frame = &raven.StacktraceFrame{Function: v} + continue + } + + // Set filename and add frame. + if frame.Filename == "" { + fld := strings.Fields(v) + if len(fld) != 2 { + frame.Filename = fileParseError + frame.AbsolutePath = v + } else { + frame.Filename = fld[0] + sp := strings.Split(fld[0], ":") + if len(sp) > 1 { + i, err := strconv.Atoi(sp[len(sp)-1]) + if err == nil { + frame.Filename = strings.Join(sp[:len(sp)-1], ":") + frame.Lineno = i + } + } + } + if frame.AbsolutePath == "" && frame.Filename != fileParseError { + frame.AbsolutePath = frame.Filename + if sp := strings.Split(frame.Filename, "/"); len(sp) > 1 { + frame.Filename = sp[len(sp)-1] + } + } + thread.Stacktrace.Frames = append([]*raven.StacktraceFrame{frame}, thread.Stacktrace.Frames...) + frame = nil + continue + } + } + // Add last thread. + s.Values = append(s.Values, thread) +} + +func findPanicSender(s *SentryThreads, err error) string { + out := "error nil" + if err != nil { + out = err.Error() + } + for _, thread := range s.Values { + if !thread.Crashed { + continue + } + for i, fr := range thread.Stacktrace.Frames { + if strings.HasSuffix(fr.Filename, "panic.go") && strings.HasPrefix(fr.Function, "panic") { + // Next frame if any. + j := 0 + if i > j { + j = i - 1 + } + + // Directory and filename. + fname := thread.Stacktrace.Frames[j].AbsolutePath + if sp := strings.Split(fname, "/"); len(sp) > 2 { + fname = strings.Join(sp[len(sp)-2:], "/") + } + + // Line number. + if ln := thread.Stacktrace.Frames[j].Lineno; ln > 0 { + fname = fmt.Sprintf("%s:%d", fname, ln) + } + + out = fmt.Sprintf("%s: %s", fname, out) + break // Just first panic. + } + } + } + return out +} + +// ReportSentryCrash reports a sentry crash with stacktrace from all goroutines. +func (c *Client) ReportSentryCrash(reportErr error) (err error) { + if reportErr == nil { + return + } + tags := map[string]string{ + "OS": runtime.GOOS, + "Client": c.config.ClientID, + "Version": c.config.AppVersion, + "UserAgent": CurrentUserAgent, + "UserID": c.userID, + } + + threads := &SentryThreads{} + threads.TraceAllRoutines() + errorWithFile := findPanicSender(threads, reportErr) + packet := raven.NewPacket(errorWithFile, threads) + + eventID, ch := raven.Capture(packet, tags) + if err = <-ch; err == nil { + c.log.Warn("Reported error with id: ", eventID) + } else { + c.log.Errorf("Can not report `%s` due to `%s`", reportErr.Error(), err.Error()) + } + return err +} diff --git a/pkg/pmapi/sentry_test.go b/pkg/pmapi/sentry_test.go new file mode 100644 index 00000000..a49f1657 --- /dev/null +++ b/pkg/pmapi/sentry_test.go @@ -0,0 +1,65 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "errors" + "testing" + + "github.com/getsentry/raven-go" +) + +func TestSentryCrashReport(t *testing.T) { + c := NewClient(testClientConfig, "bridgetest") + if err := c.ReportSentryCrash(errors.New("Testing crash report - api proxy; goroutines with threads, find origin")); err != nil { + t.Fatal("Expected no error while report, but have", err) + } +} + +func (s *SentryThreads) TraceAllRoutinesTest() { + s.Values = []Thread{ + { + ID: 0, + Name: "goroutine 20 [running]", + Crashed: true, + Stacktrace: &raven.Stacktrace{ + Frames: []*raven.StacktraceFrame{ + { + Filename: "/home/dev/build/go-1.10.2/go/src/runtime/pprof/pprof.go", + Function: "runtime/pprof.writeGoroutineStacks(0x9b7de0, 0xc4203e2900, 0xd0, 0xd0)", + Lineno: 650, + }, + }, + }, + }, + { + ID: 1, + Name: "goroutine 20 [chan receive]", + Crashed: false, + Stacktrace: &raven.Stacktrace{ + Frames: []*raven.StacktraceFrame{ + { + Filename: "/home/dev/build/go-1.10.2/go/src/testing/testing.go", + Function: "testing.(*T).Run(0xc4203e42d0, 0x90f445, 0x15, 0x97d358, 0x47a501)", + Lineno: 825, + }, + }, + }, + }, + } +} diff --git a/pkg/pmapi/server_test.go b/pkg/pmapi/server_test.go new file mode 100644 index 00000000..e6da92e8 --- /dev/null +++ b/pkg/pmapi/server_test.go @@ -0,0 +1,166 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "regexp" + "runtime" + "strconv" + "testing" + + "github.com/hashicorp/go-multierror" +) + +var ( + colRed = "\033[1;31m" + colNon = "\033[0;39m" + reHTTPCode = regexp.MustCompile(`(HTTP|get|post|put|delete)_(\d{3}).*.json`) +) + +// Assert fails the test if the condition is false. +func Assert(tb testing.TB, condition bool, msg string, v ...interface{}) { + if !condition { + _, file, line, _ := runtime.Caller(1) + vv := []interface{}{filepath.Base(file), line, colRed} + vv = append(vv, v...) + vv = append(vv, colNon) + fmt.Printf("%s:%d: %s"+msg+"%s\n\n", vv...) + tb.FailNow() + } +} + +// Ok fails the test if an err is not nil. +func Ok(tb testing.TB, err error) { + if err != nil { + _, file, line, _ := runtime.Caller(1) + fmt.Printf("%s:%d: %sunexpected error: %s%s\n\n", filepath.Base(file), line, colRed, err.Error(), colNon) + tb.FailNow() + } +} + +// Equals fails the test if exp is not equal to act. +func Equals(tb testing.TB, exp, act interface{}) { + if !reflect.DeepEqual(exp, act) { + _, file, line, _ := runtime.Caller(1) + fmt.Printf("%s:%d:\n\n%s\texp: %#v\n\n\tgot: %#v%s\n\n", filepath.Base(file), line, colRed, exp, act, colNon) + tb.FailNow() + } +} + +// newTestServer is old function and should be replaced everywhere by newTestServerCallbacks. +func newTestServer(h http.Handler) (*httptest.Server, *Client) { + s := httptest.NewServer(h) + RootURL = s.URL + + return s, newTestClient() +} + +func newTestServerCallbacks(tb testing.TB, callbacks ...func(testing.TB, http.ResponseWriter, *http.Request) string) (func(), *Client) { + reqNum := 0 + _, file, line, _ := runtime.Caller(1) + file = filepath.Base(file) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reqNum++ + if reqNum > len(callbacks) { + fmt.Printf( + "%s:%d: %sServer was requeted %d times which is more requests than expected %d%s\n\n", + file, line, colRed, reqNum, len(callbacks), colNon, + ) + tb.FailNow() + } + response := callbacks[reqNum-1](tb, w, r) + if response != "" { + writeJSONResponsefromFile(tb, w, response, reqNum-1) + } + })) + RootURL = server.URL + finish := func() { + server.CloseClientConnections() // Closing without waiting for finishing requests. + if reqNum != len(callbacks) { + fmt.Printf( + "%s:%d: %sServer was requested %d times but expected to be %d times%s\n\n", + file, line, colRed, reqNum, len(callbacks), colNon, + ) + tb.Error("server failed") + } + } + return finish, newTestClient() +} + +func checkMethodAndPath(r *http.Request, method, path string) error { + var result *multierror.Error + if err := checkHeader(r.Header, "x-pm-appversion", "GoPMAPI_1.0.14"); err != nil { + result = multierror.Append(result, err) + } + if err := checkHeader(r.Header, "x-pm-apiversion", "3"); err != nil { + result = multierror.Append(result, err) + } + if r.Method != method { + err := fmt.Errorf("Invalid request method expected %v, got %v", method, r.Method) + result = multierror.Append(result, err) + } + if r.URL.RequestURI() != path { + err := fmt.Errorf("Invalid request path expected %v, got %v", path, r.URL.RequestURI()) + result = multierror.Append(result, err) + } + return result.ErrorOrNil() +} + +func httpResponse(code int) string { + return fmt.Sprintf("HTTP_%d.json", code) +} + +func writeJSONResponsefromFile(tb testing.TB, w http.ResponseWriter, response string, reqNum int) { + if match := reHTTPCode.FindAllSubmatch([]byte(response), -1); len(match) != 0 { + httpCode, err := strconv.Atoi(string(match[0][len(match[0])-1])) + Ok(tb, err) + w.WriteHeader(httpCode) + } + f, err := os.Open("./testdata/routes/" + response) + Ok(tb, err) + w.Header().Set("content-type", "application/json;charset=utf-8") + w.Header().Set("x-test-pmapi-response", fmt.Sprintf("%s:%d", tb.Name(), reqNum)) + _, err = io.Copy(w, f) + Ok(tb, err) +} + +func checkHeader(h http.Header, field, exp string) error { + val := h.Get(field) + if val != exp { + msg := "wrong field %s expected %q but have %q" + return fmt.Errorf(msg, field, exp, val) + } + return nil +} + +func isAuthReq(r *http.Request, uid, token string) error { // nolint[unparam] + if err := checkHeader(r.Header, "x-pm-uid", uid); err != nil { + return err + } + if err := checkHeader(r.Header, "authorization", "Bearer "+token); err != nil { + return err + } + return nil +} diff --git a/pkg/pmapi/settings.go b/pkg/pmapi/settings.go new file mode 100644 index 00000000..a0f89125 --- /dev/null +++ b/pkg/pmapi/settings.go @@ -0,0 +1,118 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +type UserSettings struct { + PasswordMode int + Email struct { + Value string + Status int + Notify int + Reset int + } + Phone struct { + Value string + Status int + Notify int + Reset int + } + News int + Locale string + LogAuth string + InvoiceText string + TOTP int + U2FKeys []struct { + Label string + KeyHandle string + Compromised int + } +} + +// GetUserSettings gets general settings. +func (c *Client) GetUserSettings() (settings UserSettings, err error) { + req, err := NewRequest("GET", "/settings", nil) + + if err != nil { + return + } + + var res struct { + Res + UserSettings UserSettings + } + + if err = c.DoJSON(req, &res); err != nil { + return + } + + return res.UserSettings, res.Err() +} + +type MailSettings struct { + DisplayName string + Signature string `json:",omitempty"` + Theme string `json:",omitempty"` + AutoSaveContacts int + AutoWildcardSearch int + ComposerMode int + MessageButtons int + ShowImages int + ShowMoved int + ViewMode int + ViewLayout int + SwipeLeft int + SwipeRight int + AlsoArchive int + Hotkeys int + PMSignature int + ImageProxy int + TLS int + RightToLeft int + AttachPublicKey int + Sign int + PGPScheme int + PromptPin int + Autocrypt int + NumMessagePerPage int + DraftMIMEType string + ReceiveMIMEType string + ShowMIMEType string + + // Undocumented -- there's only `null` in example: + // AutoResponder string +} + +// GetMailSettings gets contact details specified by contact ID. +func (c *Client) GetMailSettings() (settings MailSettings, err error) { + req, err := NewRequest("GET", "/settings/mail", nil) + + if err != nil { + return + } + + var res struct { + Res + MailSettings MailSettings + } + + if err = c.DoJSON(req, &res); err != nil { + return + } + + return res.MailSettings, res.Err() +} diff --git a/pkg/pmapi/testdata/keyring_addressKeysPrimaryHasToken_JSON b/pkg/pmapi/testdata/keyring_addressKeysPrimaryHasToken_JSON new file mode 100644 index 00000000..650d42ab --- /dev/null +++ b/pkg/pmapi/testdata/keyring_addressKeysPrimaryHasToken_JSON @@ -0,0 +1,22 @@ +[ + { + "ID": "hKRtZeTDhvzfAaycb5BOVx6Y3hc3gs4QvET8H_YZBTwAQBPp3h6FI4nnkJePCYuM9CG0zf7TQzOJeB2rPi0YmQ==", + "Primary": 1, + "Flags": 3, + "Version": 3, + "Activation": null, + "PrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nxcMGBFzGzj4BCADh9uuhG3mDhlbFMgQpvP6AReyJYa6KSV9a6AJsNI0/DkVz\nFenIodav/r4RBgSoblqxCa/QH6galbX0eB8Vtxx4xTXCcHHZzTM9oAb4s8r6\ndGchNsSkoLspVwmc2fYfDRCTcapmeza7Jym8gD9wFUSWSwpC49jDeT6VIcOd\n+qNXgIJX1V36BOAB53lKenMYnxfzzAyJDeL/sQ/SWE9h/X+wGdkVmk7gk3pa\nQ9URoxAf7MO808hHyQYcjTV4tOHD/9k+AZC6sqmr2KAefYCxCMoCDj1wQp3J\nN6MfK2Zvc1r5KFQM2YzUXrMNrycAjo7gGeZnsvz758Q+8ouSP7iXGRl/ABEB\nAAH+CQMIQt3QQIJZYjRgTVgIQNSEkhMn4GJ5lQQPBXD0/YyK3xP0bm/bsX8S\nz9dAm1nkP0l8Q9z3lR6OcAX2O/KsqJdQGmJEZAaadz2je+EEWo1FDYaL76E6\n6z+AuteP8UtP61jBt3pTfIlhW8o7o2SlM1lvytpvc5FQplZ/iUuwfsIltCiD\n9VDkprNOVrRsjoh8BV4TrrIvU8qga58Aeg7SjOmm+3oMZ7yPTYirr11Tx/m3\nj1uzdEDfiPk4LmvlzSsWwKZuy6fSul+n92+9qN81wmdts/I2ucuKvOINZim4\nlk/p2AOsPjWpAgkefpTVLZnu2IH+VAyaXt1Fl84badXx4N921nPs7ova1Ud5\n2RddBc7b/01DtOyBSWDoNskLGpsc2mqz9kdkwwQKNjChzZc9nmY9M+AIfgT3\n+2DSQIuoJYPX69DKi/bZDwRzoHmiwiHT6Us7qxd6kD1dzCIHTptxwZQp4Tow\nnN6lmtK4S6O2B47+ROn8s0N+EH9GR8F6mvOTayNLH5yicpR3M4Of0ClvFa0G\n+JUKBXIQXUvF03G3nTPU17nLibC81UmbK3zobfbrLfuU2gU+sY+OfE1E/+GO\nSFpZcrkoRqRr39CfLkLk+GjU7RCLNddb5LxgaurVZo5h0Y7Rr8VvOQMWjjl/\nvTAG7gU/HtXi24TijNC0fP6j9w43K4b6t1SZYn5us7RRlFlWGKMSPf5Q9j6/\nryo3xULUQv0lTCQPtfQQ5UWE5ZpQ4Kjt/k5+/YtfuOcMbrDU4qa+H+rrisBu\nko7f4Wn0iYjRwRIuWh1NfUM3rIbNhq7/wonasEFOeFdPwprzMaawx3rL0Pq5\nGs/4LONqG61c9rBekbkGf7Jlkuq/5yo5RBgPnKwvJKsHqf1evD6kHC3aOfeO\n30UoMwe7g763pOXZrsOpZfPzxmraJJSYzS0iYXJvbjIxLTRAc2FkZW1iZS5v\ncmciIDxhcm9uMjEtNEBzYWRlbWJlLm9yZz7CwHUEEAEIAB8FAlzGzj4GCwkH\nCAMCBBUICgIDFgIBAhkBAhsDAh4BAAoJEC3c5LbRFpy6lOgH/RKA4QaTnCi8\nc7HHhDZncqwBSUNhTjCAvoiireX9gGm9JugxaPxHVH4RzznY6R7/Ui8Ak7S8\n+k/xhHbsOGc2exyWwUN1X3WJY3jSX2HNqDU4qw6hSwBGReYbOWJeKGhJWild\nPS4V6u6manGWXxQmW1XET3B0P72VJVSX1kUbslPBAhKbW3JCnDmEdV/sU6Ds\nXdh543Yph1OpO2Fq7b/+YYUDAzmHf/+k4ijVcvqjrjUCJJwv+2J9woi4ToW6\n3BQVG5gpAYzCfgoJjlaigInhoFrBjP25Oe6/ssDTssGJrHXhtyc8e+b7nm19\nSHOpWGcUn2F1+tU+E4O8SLCLGJxefJvHwwYEXMbOPgEIAKEkpHRBhTWOIeYB\ngPXh/Ng77x3Bs6EKwTQM/BePYC2uS+15+nIpiYHhb/sQ9aEbQgqmyfbkbfIf\n9Qahx4N9RFyqWcmSjfk0Bmo9xOziRQm1tfYkbUwkeI6NIr2ENUWVf7tt+UKz\n5dFmvSKsyrdPEtt+Ken17JoihhJ/9saLMkLn5Y3HrSGVXIniX1cQuarXGX9S\nyt7jIPeGZW2suuxlnlB6Sa/rCkaqR3C3a8knxiH8CDwAm+E8a1d/UbQ0np0k\nqTVVrc2fmxPMgZxGWrwIsO9D1Fs7dgw5rac7ijHvPXeWrzbMd1+rX5mmLF4+\nH2PDOUKN0Uu8WdVilCIEOMoVfY0AEQEAAf4JAwiPkDWd2zmFhmBFMZgWR+X2\nhS4q8bIohwFb8MDzrHAvtI8LOaxC32j3/tUwJKDwLNDeiiOGYfHUOmqgzVRi\nMBdkoVNT3OyFLbw6k/70spe5OcecZsM+OAQybX77Kv3H/VW/40TtUh52Zvvk\nqCoqtG86C4R5kma0zM+yvNyprkCIRwYuJ+OkdmMvem3fRqU49GNzJChBDAmZ\nfGc7W7r2JFIo1k88bh+kaGlDBA9p8GnA7KNXAlBdq6owJNJA0j2z6Rmngpip\nFuUEjdW8Kcs2ben4BZOWGgYmOmD1CAHRYm/xp+Us1kJDTTFxW7P7BNp1kVOP\n9s9AcV/t37bCHLVB2IjAb/tkOxyAexMTS/lFJrpJ89MpYiUt70uB+SDwRls9\noud9CQYS+6OdddgFUjtnOkpcR1Y24v1eF13JwUXmnPggvt6Do3gS6lGdq0Nt\nqNDg8+yPyMA8365Q8IbJDuzm3vfc560Szc/Kx4+1zr57Uaw/qYqhZHSkjRtZ\nfTP0v0ZNwxyXhGF3J31neJ5KgIO+zpSWSP8RwtUVBb8Tsyn8e/DSe/8fAs3V\ntmyxSj9mPQLxW8JCQMpocExEJV7PnxjhB0d1TlYu/wRUl4sxpS4pYa/TDhb9\n37oUYtG28TufT7AZE+XcU8Y1Xl7DVi5jgadUAI5rY71G31JeBuIcGRlVLBnT\nSHnu1v2iz1xqZKQFynH6DUrySn8nP8NM9TaVEnSBlVCjHcyvrs8crx358YFV\n4TG44XQ3n5GLjPetKGD/ccMYOUZm2jkLNzY9l4YqKJq7xsm2c5VhwUEB51Pi\n8Ey+x7hO3EjWUgJrtI/3/hPoKlltPUvnhJjNrR8ivocvd2v+2U0BHQOwTUt3\njSDssFDI9hfOMPp4yy/GHM+p3USC+3TMS8HjFbm6b2bIWzqEv83AaauZqiE1\nfJfCwF8EGAEIAAkFAlzGzj4CGwwACgkQLdzkttEWnLqI8Af/Uimov/qMfFdi\nSp1qYTzR9V84ZkfxBAcr9nEyNGtMlvCwrs3EtZPBe7ou57Qt9aI8UjJfsiov\naHUC1Gt8266T1+GGj6RCPSm6Sp7cZxGtURZDWAPb5u02+VtrqrrXQgkCxQ8/\nzWfg4vLyl052x+3F9SGy7SvH0qi8bcHzVjcwK9VyoSdBq/vhkPHQ33wLQ9ND\nuQCM8fxW+VOqMDlUCHfdcaYRYU7GEm9C37ZijpOLZRuAm6ojjCGtrOrGQ+y0\ni3q7Zn1yDradMlMGG9GjDlqOCmYhZbuT4uS578GzPg0L8zk/1rFOF+YY6Mfq\nV0GB3IX8qBHjAfPmqN9JPxBIn3/6ag==\n=U1yD\n-----END PGP PRIVATE KEY BLOCK-----\n", + "Token": "-----BEGIN PGP MESSAGE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwcBMAxW/uQhm1PKxAQf8D4BAoXqnYgoTr7jEiabP9NkeuD6Z88fZDgUzXte5\n6aYMmCtPr0Vef1eDl2x0S0q/YJgR+A5Icmjxk+jG9nMSnRPlAozJfTCXu/Oy\nfkI4CcClGQv7U1EzfknBVNEKmuPO8XlEkGZ5lW/TzWk8ZtQqJsVuqZlZ5qQE\n/p8FNYEZ5RHPhhKlotA+T6XKh23z9mcN/JNEsSNcx+guERbivtTwnd5JpxQJ\nMNWzUYh+K0W7LgxoAo2jevDd4CZ/0sAAViIl+QrShodseV9agbldGUUoIMzn\nHUuIV6VYi7X82eKQXDInrtPc9IHekbDDFoncLnsGGrEqD/8O/qOHHOPwNwU+\n+cHATAOUTDv/ccWq1QEIALl8SNOQBmCuanAceUTSwCkM1fC3Ddqa8ZmnMMyG\nnDWDI7XkFU31CO10lN20/kAWjdN2073B/NT+17cp8fCqbQ1pAFJHpabdqmbI\ntm3pCC5M6otTN+MhjgFYcBuxo0rq9qtuEzz5j4Ub9MIIJTurUHMEPMI462Dc\ndK/d8BvDkU7q67Lkp65vpe9e/pv0lMMrQjdohnTHNgbZhbI/Z5LU0ApD//Ye\ncSC26BRUMJITiGKb9pKGAi0/ig0jJfzykgEarzOsY/v0W7016AMka+NsHuNc\n7Qfyg2LApF5s9Z6aK+Uy851haUk+p+abjBdcWACziAmimVGjlRYm49ra4UJ7\nc6LSeAElM584e4Z3VnqajAJWbmWt2atgBQmcwEBBsdNAtzIagNydMbBBIELA\n5wGUbMXfQxrMOo/Mdac/5EMTHT0/kVQNIBHtpX5SknWvbc5DjIHoH3+BbL87\neaxhKW94hMubKNKT3dbm4PKHtsMiS3TGkFZ3GjvA0Cy/Kw==\n=b1N8\n-----END PGP MESSAGE-----\n", + "Signature": "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAGBQJcxtSkAAoJEGfweL9m9sQefz8IAJhpEOvG7d+PVgq3bEL+\nXzY8yTUKB1ZXEMbcR/uHUWSfu0F8zy4CNtSG5HUVjNxx1xzzJVJcxWB/ljO+\n/bJSFOFexTyNh8i/xU+CiBfm5RhAFTYF9xFwfD3LKp5gaalJAhWhArk1/Wuh\nWTDpFpk39uzBRKNwcnSgiJYPxOjAZxj+w/hhHPwmco5cUwMiMR5MNrfzKf+x\nwX3Cfs9fsiiCDzohBzbK0FFsMnJ8aXNVsDBjEA1KrB7sdyaf8FnaM0RsFRDb\njxtIgfcFBbyNJh4414Unt4AYTIrIWhK4OOXI3AfsJy8p6KRBKQUcUkKcDxab\nPTXOPZsZ+UgQ5MevyVnP1zfCwFwEAQEIAAYFAlzG1KQACgkQm1DMMBzxetx/\nPwf9F04uHtix0zDpP1IvG4VYlor4rjYTdfXqxxiFXHO6MZXJoigS1E71E8r4\nsqZ6PoQ5/xCj2A01KRhuF1Bon3mEZEwaIUuBqTV91sLsVWfoxgyPAYpr6gK/\n1W9JhVNNrVRMGox42LQUjyiq0ESrfWmqC8SuMfZMoUoBZycHicA50RbyOUnT\nLO57ArL3JIVmYtyosaXM3idzmNHmaXSkcGt4cvTVysJZQrneaxmikfm5CH2O\n1z2goLBNnzsbRionoV6gCukZOiM/d14yiyeYtsFJ7u/vodkI5y0M0sF8VnN/\njL9keP4ZpHiJ4MBd6tyIH0pLueDRlurFL3fcHsEzD5SrOA==\n=wGCk\n-----END PGP SIGNATURE-----\n" + }, + { + "ID": "MhF8dxtN5Lz_GruskN3L9kTKWusZHBdhWaxc7w1tgze2qB6uM9AyC6mWRQg1B7WGZ_r-9gn-XC95-IkpNJr0jg==", + "Primary": 0, + "Flags": 3, + "Version": 3, + "Activation": null, + "PrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----\n\n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\nYYsMDlA=\n=ZwJf\n-----END PGP PRIVATE KEY BLOCK-----", + "Token": null, + "Signature": null + } +] \ No newline at end of file diff --git a/pkg/pmapi/testdata/keyring_addressKeysSecondaryHasToken_JSON b/pkg/pmapi/testdata/keyring_addressKeysSecondaryHasToken_JSON new file mode 100644 index 00000000..b4a78815 --- /dev/null +++ b/pkg/pmapi/testdata/keyring_addressKeysSecondaryHasToken_JSON @@ -0,0 +1,22 @@ +[ + { + "ID": "MhF8dxtN5Lz_GruskN3L9kTKWusZHBdhWaxc7w1tgze2qB6uM9AyC6mWRQg1B7WGZ_r-9gn-XC95-IkpNJr0jg==", + "Primary": 1, + "Flags": 3, + "Version": 3, + "Activation": null, + "PrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----\n\n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\nYYsMDlA=\n=ZwJf\n-----END PGP PRIVATE KEY BLOCK-----", + "Token": null, + "Signature": null + }, + { + "ID": "hKRtZeTDhvzfAaycb5BOVx6Y3hc3gs4QvET8H_YZBTwAQBPp3h6FI4nnkJePCYuM9CG0zf7TQzOJeB2rPi0YmQ==", + "Primary": 0, + "Flags": 3, + "Version": 3, + "Activation": null, + "PrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nxcMGBFzGzj4BCADh9uuhG3mDhlbFMgQpvP6AReyJYa6KSV9a6AJsNI0/DkVz\nFenIodav/r4RBgSoblqxCa/QH6galbX0eB8Vtxx4xTXCcHHZzTM9oAb4s8r6\ndGchNsSkoLspVwmc2fYfDRCTcapmeza7Jym8gD9wFUSWSwpC49jDeT6VIcOd\n+qNXgIJX1V36BOAB53lKenMYnxfzzAyJDeL/sQ/SWE9h/X+wGdkVmk7gk3pa\nQ9URoxAf7MO808hHyQYcjTV4tOHD/9k+AZC6sqmr2KAefYCxCMoCDj1wQp3J\nN6MfK2Zvc1r5KFQM2YzUXrMNrycAjo7gGeZnsvz758Q+8ouSP7iXGRl/ABEB\nAAH+CQMIQt3QQIJZYjRgTVgIQNSEkhMn4GJ5lQQPBXD0/YyK3xP0bm/bsX8S\nz9dAm1nkP0l8Q9z3lR6OcAX2O/KsqJdQGmJEZAaadz2je+EEWo1FDYaL76E6\n6z+AuteP8UtP61jBt3pTfIlhW8o7o2SlM1lvytpvc5FQplZ/iUuwfsIltCiD\n9VDkprNOVrRsjoh8BV4TrrIvU8qga58Aeg7SjOmm+3oMZ7yPTYirr11Tx/m3\nj1uzdEDfiPk4LmvlzSsWwKZuy6fSul+n92+9qN81wmdts/I2ucuKvOINZim4\nlk/p2AOsPjWpAgkefpTVLZnu2IH+VAyaXt1Fl84badXx4N921nPs7ova1Ud5\n2RddBc7b/01DtOyBSWDoNskLGpsc2mqz9kdkwwQKNjChzZc9nmY9M+AIfgT3\n+2DSQIuoJYPX69DKi/bZDwRzoHmiwiHT6Us7qxd6kD1dzCIHTptxwZQp4Tow\nnN6lmtK4S6O2B47+ROn8s0N+EH9GR8F6mvOTayNLH5yicpR3M4Of0ClvFa0G\n+JUKBXIQXUvF03G3nTPU17nLibC81UmbK3zobfbrLfuU2gU+sY+OfE1E/+GO\nSFpZcrkoRqRr39CfLkLk+GjU7RCLNddb5LxgaurVZo5h0Y7Rr8VvOQMWjjl/\nvTAG7gU/HtXi24TijNC0fP6j9w43K4b6t1SZYn5us7RRlFlWGKMSPf5Q9j6/\nryo3xULUQv0lTCQPtfQQ5UWE5ZpQ4Kjt/k5+/YtfuOcMbrDU4qa+H+rrisBu\nko7f4Wn0iYjRwRIuWh1NfUM3rIbNhq7/wonasEFOeFdPwprzMaawx3rL0Pq5\n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\nV0GB3IX8qBHjAfPmqN9JPxBIn3/6ag==\n=U1yD\n-----END PGP PRIVATE KEY BLOCK-----\n", + "Token": "-----BEGIN PGP MESSAGE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwcBMAxW/uQhm1PKxAQf8D4BAoXqnYgoTr7jEiabP9NkeuD6Z88fZDgUzXte5\n6aYMmCtPr0Vef1eDl2x0S0q/YJgR+A5Icmjxk+jG9nMSnRPlAozJfTCXu/Oy\nfkI4CcClGQv7U1EzfknBVNEKmuPO8XlEkGZ5lW/TzWk8ZtQqJsVuqZlZ5qQE\n/p8FNYEZ5RHPhhKlotA+T6XKh23z9mcN/JNEsSNcx+guERbivtTwnd5JpxQJ\nMNWzUYh+K0W7LgxoAo2jevDd4CZ/0sAAViIl+QrShodseV9agbldGUUoIMzn\nHUuIV6VYi7X82eKQXDInrtPc9IHekbDDFoncLnsGGrEqD/8O/qOHHOPwNwU+\n+cHATAOUTDv/ccWq1QEIALl8SNOQBmCuanAceUTSwCkM1fC3Ddqa8ZmnMMyG\nnDWDI7XkFU31CO10lN20/kAWjdN2073B/NT+17cp8fCqbQ1pAFJHpabdqmbI\ntm3pCC5M6otTN+MhjgFYcBuxo0rq9qtuEzz5j4Ub9MIIJTurUHMEPMI462Dc\ndK/d8BvDkU7q67Lkp65vpe9e/pv0lMMrQjdohnTHNgbZhbI/Z5LU0ApD//Ye\ncSC26BRUMJITiGKb9pKGAi0/ig0jJfzykgEarzOsY/v0W7016AMka+NsHuNc\n7Qfyg2LApF5s9Z6aK+Uy851haUk+p+abjBdcWACziAmimVGjlRYm49ra4UJ7\nc6LSeAElM584e4Z3VnqajAJWbmWt2atgBQmcwEBBsdNAtzIagNydMbBBIELA\n5wGUbMXfQxrMOo/Mdac/5EMTHT0/kVQNIBHtpX5SknWvbc5DjIHoH3+BbL87\neaxhKW94hMubKNKT3dbm4PKHtsMiS3TGkFZ3GjvA0Cy/Kw==\n=b1N8\n-----END PGP MESSAGE-----\n", + "Signature": "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAGBQJcxtSkAAoJEGfweL9m9sQefz8IAJhpEOvG7d+PVgq3bEL+\nXzY8yTUKB1ZXEMbcR/uHUWSfu0F8zy4CNtSG5HUVjNxx1xzzJVJcxWB/ljO+\n/bJSFOFexTyNh8i/xU+CiBfm5RhAFTYF9xFwfD3LKp5gaalJAhWhArk1/Wuh\nWTDpFpk39uzBRKNwcnSgiJYPxOjAZxj+w/hhHPwmco5cUwMiMR5MNrfzKf+x\nwX3Cfs9fsiiCDzohBzbK0FFsMnJ8aXNVsDBjEA1KrB7sdyaf8FnaM0RsFRDb\njxtIgfcFBbyNJh4414Unt4AYTIrIWhK4OOXI3AfsJy8p6KRBKQUcUkKcDxab\nPTXOPZsZ+UgQ5MevyVnP1zfCwFwEAQEIAAYFAlzG1KQACgkQm1DMMBzxetx/\nPwf9F04uHtix0zDpP1IvG4VYlor4rjYTdfXqxxiFXHO6MZXJoigS1E71E8r4\nsqZ6PoQ5/xCj2A01KRhuF1Bon3mEZEwaIUuBqTV91sLsVWfoxgyPAYpr6gK/\n1W9JhVNNrVRMGox42LQUjyiq0ESrfWmqC8SuMfZMoUoBZycHicA50RbyOUnT\nLO57ArL3JIVmYtyosaXM3idzmNHmaXSkcGt4cvTVysJZQrneaxmikfm5CH2O\n1z2goLBNnzsbRionoV6gCukZOiM/d14yiyeYtsFJ7u/vodkI5y0M0sF8VnN/\njL9keP4ZpHiJ4MBd6tyIH0pLueDRlurFL3fcHsEzD5SrOA==\n=wGCk\n-----END PGP SIGNATURE-----\n" + } +] \ No newline at end of file diff --git a/pkg/pmapi/testdata/keyring_addressKeysWithTokens_JSON b/pkg/pmapi/testdata/keyring_addressKeysWithTokens_JSON new file mode 100644 index 00000000..b8b382d6 --- /dev/null +++ b/pkg/pmapi/testdata/keyring_addressKeysWithTokens_JSON @@ -0,0 +1,12 @@ +[ + { + "ID": "hKRtZeTDhvzfAaycb5BOVx6Y3hc3gs4QvET8H_YZBTwAQBPp3h6FI4nnkJePCYuM9CG0zf7TQzOJeB2rPi0YmQ==", + "Primary": 1, + "Flags": 3, + "Version": 3, + "Activation": null, + "PrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nxcMGBFzGzj4BCADh9uuhG3mDhlbFMgQpvP6AReyJYa6KSV9a6AJsNI0/DkVz\nFenIodav/r4RBgSoblqxCa/QH6galbX0eB8Vtxx4xTXCcHHZzTM9oAb4s8r6\ndGchNsSkoLspVwmc2fYfDRCTcapmeza7Jym8gD9wFUSWSwpC49jDeT6VIcOd\n+qNXgIJX1V36BOAB53lKenMYnxfzzAyJDeL/sQ/SWE9h/X+wGdkVmk7gk3pa\nQ9URoxAf7MO808hHyQYcjTV4tOHD/9k+AZC6sqmr2KAefYCxCMoCDj1wQp3J\nN6MfK2Zvc1r5KFQM2YzUXrMNrycAjo7gGeZnsvz758Q+8ouSP7iXGRl/ABEB\nAAH+CQMIQt3QQIJZYjRgTVgIQNSEkhMn4GJ5lQQPBXD0/YyK3xP0bm/bsX8S\nz9dAm1nkP0l8Q9z3lR6OcAX2O/KsqJdQGmJEZAaadz2je+EEWo1FDYaL76E6\n6z+AuteP8UtP61jBt3pTfIlhW8o7o2SlM1lvytpvc5FQplZ/iUuwfsIltCiD\n9VDkprNOVrRsjoh8BV4TrrIvU8qga58Aeg7SjOmm+3oMZ7yPTYirr11Tx/m3\nj1uzdEDfiPk4LmvlzSsWwKZuy6fSul+n92+9qN81wmdts/I2ucuKvOINZim4\nlk/p2AOsPjWpAgkefpTVLZnu2IH+VAyaXt1Fl84badXx4N921nPs7ova1Ud5\n2RddBc7b/01DtOyBSWDoNskLGpsc2mqz9kdkwwQKNjChzZc9nmY9M+AIfgT3\n+2DSQIuoJYPX69DKi/bZDwRzoHmiwiHT6Us7qxd6kD1dzCIHTptxwZQp4Tow\nnN6lmtK4S6O2B47+ROn8s0N+EH9GR8F6mvOTayNLH5yicpR3M4Of0ClvFa0G\n+JUKBXIQXUvF03G3nTPU17nLibC81UmbK3zobfbrLfuU2gU+sY+OfE1E/+GO\nSFpZcrkoRqRr39CfLkLk+GjU7RCLNddb5LxgaurVZo5h0Y7Rr8VvOQMWjjl/\nvTAG7gU/HtXi24TijNC0fP6j9w43K4b6t1SZYn5us7RRlFlWGKMSPf5Q9j6/\nryo3xULUQv0lTCQPtfQQ5UWE5ZpQ4Kjt/k5+/YtfuOcMbrDU4qa+H+rrisBu\nko7f4Wn0iYjRwRIuWh1NfUM3rIbNhq7/wonasEFOeFdPwprzMaawx3rL0Pq5\n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\nV0GB3IX8qBHjAfPmqN9JPxBIn3/6ag==\n=U1yD\n-----END PGP PRIVATE KEY BLOCK-----\n", + "Token": "-----BEGIN PGP MESSAGE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwcBMAxW/uQhm1PKxAQf8D4BAoXqnYgoTr7jEiabP9NkeuD6Z88fZDgUzXte5\n6aYMmCtPr0Vef1eDl2x0S0q/YJgR+A5Icmjxk+jG9nMSnRPlAozJfTCXu/Oy\nfkI4CcClGQv7U1EzfknBVNEKmuPO8XlEkGZ5lW/TzWk8ZtQqJsVuqZlZ5qQE\n/p8FNYEZ5RHPhhKlotA+T6XKh23z9mcN/JNEsSNcx+guERbivtTwnd5JpxQJ\nMNWzUYh+K0W7LgxoAo2jevDd4CZ/0sAAViIl+QrShodseV9agbldGUUoIMzn\nHUuIV6VYi7X82eKQXDInrtPc9IHekbDDFoncLnsGGrEqD/8O/qOHHOPwNwU+\n+cHATAOUTDv/ccWq1QEIALl8SNOQBmCuanAceUTSwCkM1fC3Ddqa8ZmnMMyG\nnDWDI7XkFU31CO10lN20/kAWjdN2073B/NT+17cp8fCqbQ1pAFJHpabdqmbI\ntm3pCC5M6otTN+MhjgFYcBuxo0rq9qtuEzz5j4Ub9MIIJTurUHMEPMI462Dc\ndK/d8BvDkU7q67Lkp65vpe9e/pv0lMMrQjdohnTHNgbZhbI/Z5LU0ApD//Ye\ncSC26BRUMJITiGKb9pKGAi0/ig0jJfzykgEarzOsY/v0W7016AMka+NsHuNc\n7Qfyg2LApF5s9Z6aK+Uy851haUk+p+abjBdcWACziAmimVGjlRYm49ra4UJ7\nc6LSeAElM584e4Z3VnqajAJWbmWt2atgBQmcwEBBsdNAtzIagNydMbBBIELA\n5wGUbMXfQxrMOo/Mdac/5EMTHT0/kVQNIBHtpX5SknWvbc5DjIHoH3+BbL87\neaxhKW94hMubKNKT3dbm4PKHtsMiS3TGkFZ3GjvA0Cy/Kw==\n=b1N8\n-----END PGP MESSAGE-----\n", + "Signature": "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAGBQJcxtSkAAoJEGfweL9m9sQefz8IAJhpEOvG7d+PVgq3bEL+\nXzY8yTUKB1ZXEMbcR/uHUWSfu0F8zy4CNtSG5HUVjNxx1xzzJVJcxWB/ljO+\n/bJSFOFexTyNh8i/xU+CiBfm5RhAFTYF9xFwfD3LKp5gaalJAhWhArk1/Wuh\nWTDpFpk39uzBRKNwcnSgiJYPxOjAZxj+w/hhHPwmco5cUwMiMR5MNrfzKf+x\nwX3Cfs9fsiiCDzohBzbK0FFsMnJ8aXNVsDBjEA1KrB7sdyaf8FnaM0RsFRDb\njxtIgfcFBbyNJh4414Unt4AYTIrIWhK4OOXI3AfsJy8p6KRBKQUcUkKcDxab\nPTXOPZsZ+UgQ5MevyVnP1zfCwFwEAQEIAAYFAlzG1KQACgkQm1DMMBzxetx/\nPwf9F04uHtix0zDpP1IvG4VYlor4rjYTdfXqxxiFXHO6MZXJoigS1E71E8r4\nsqZ6PoQ5/xCj2A01KRhuF1Bon3mEZEwaIUuBqTV91sLsVWfoxgyPAYpr6gK/\n1W9JhVNNrVRMGox42LQUjyiq0ESrfWmqC8SuMfZMoUoBZycHicA50RbyOUnT\nLO57ArL3JIVmYtyosaXM3idzmNHmaXSkcGt4cvTVysJZQrneaxmikfm5CH2O\n1z2goLBNnzsbRionoV6gCukZOiM/d14yiyeYtsFJ7u/vodkI5y0M0sF8VnN/\njL9keP4ZpHiJ4MBd6tyIH0pLueDRlurFL3fcHsEzD5SrOA==\n=wGCk\n-----END PGP SIGNATURE-----\n" + } +] \ No newline at end of file diff --git a/pkg/pmapi/testdata/keyring_addressKeysWithoutTokens_JSON b/pkg/pmapi/testdata/keyring_addressKeysWithoutTokens_JSON new file mode 100644 index 00000000..6f4320e2 --- /dev/null +++ b/pkg/pmapi/testdata/keyring_addressKeysWithoutTokens_JSON @@ -0,0 +1,12 @@ +[ + { + "ID": "MhF8dxtN5Lz_GruskN3L9kTKWusZHBdhWaxc7w1tgze2qB6uM9AyC6mWRQg1B7WGZ_r-9gn-XC95-IkpNJr0jg==", + "Primary": 1, + "Flags": 3, + "Version": 3, + "Activation": null, + "PrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----\n\nlQPGBF2IcLUBCACl7vans5JKYAEuOZfKNDLZKmLO675DhLjbno8Pw4Q8O6xQETUZ\nTFsIB6eTigCqaZasCeHIx4r7CI4TY2uZvV3+JNaAHN0omHh3iwVXQiE3FU5Zc6TO\niVswOoWPbVYFTuItP3FQHuossMIralIzG0/AyDx0NJawwpSxxz9+C0UoYeZD1A95\nfKveRVhDW0MgO1peDHrpr2HiWv06KkIop39CD/MBnBtDpsarGd16wvX+r8F3MrNE\nD1/e6szzi7oR5H0hPs+HSWZexXhiGSWoEyMiklX/sG/9+LxnHqPhCj7lrGfzmL7j\nCFuHZIqpIO8wVggOQYx1mtmMu7T9PZm66WMbABEBAAH+BwMCYpZ0ni+oe4DxIByk\nseTvdCIjQ3ieiTVvg9FsIVrupmIxDK9EVsGw1/sFjBtc5OOP8N9Vd8IyPh6WjBMu\n5pI5dsYKCLy6LMDmYTYQzXgWlpq2cpH+yZX21fZ+0ijEu+TTSJss3emCULrtCS5M\nUTzt813cIPJu8bYPdQ7taVRqK0Bo+S8YTj2a+lxHQRkbRclueuTm7l5ypU/9X4Xf\n4zwUKnq0OYu3U8IkDEOA+4Zv4SZssFrPr8plUzMHUahVV7NZCXOG11ju1CC9CkF2\n3lpkCZw96TwCGQnT4iTPix1NEJZo6ihRbMlnLo/neS3TWgdCpK5Qr8x4B82ykX6J\ngUeNozW/YFOwFwah22TeSN2dbaGbzJOSqMXHVzK9mz/oTajY/51pS/ZxGoxgy7F2\nD8E/m0I7dS3fw/A/6ZtQFa1bW8L/dBnN71gfr6PSVxn3oczPCXtzNeqHPaDIDGV1\nhgGbGFgmjJy9mnPuxQcScIJloLOVerAaqMcgFuo2MhGKLzQ7IGiSzinaJZ7k602f\nJPfpqBg2aMO9xxNC0S6p6KzMRZCjM1cOJYF2jMo2UWqM0XA1+aEqPksBTmyKSd9+\n1sc8coVl3iesji6GZoT5s0+sNEiQZuNOFUzmGKdgqZhDVfPTLmfV1Uv/tlZCGKmC\ntC51VGjqf8RE3ycdn0Hby8Q2DLsaTnC2vMUde2iJwGQU41TTlt4TKSp6YtqSxbp+\nzUpF4QuS2zVARtx3Z87vw6rHmrNCXHzxl9zWjF3M42FhEESrUL0dciM3c1pB5twd\nIx1TSI0+96zneGS4AkN5Tqua+RdvWXX8ljRgLFa8DqraumQyOjyMXRlJoiey1dt3\n3sRfeF8eI2QF9DooI+4Za/C/1VaEURSQ4p04Ng/xJJ9mwpxjKvxmujC9n2yZ1u4o\nvQh9cKQ9nQAItDdqYW1lcy10ZXN0QHByb3Rvbm1haWwuYmx1ZSA8amFtZXMtdGVz\ndEBwcm90b25tYWlsLmJsdWU+iQE1BBABCAAfBQJdiHC1BgsJBwgDAgQVCAoCAxYC\nAQIZAQIbAwIeAQAKCRC/qkLbur13GVuWB/410LkZxZltVA1odI+yL3utIhoNWj8R\n3AhlVPLfAymkJOFv2KMZ2pRkS8PTmq4sYjAlti60cpWwrNdU4eSfNxoWDEg2lILW\nFULm3TwygDFogSsqyKlGXGr0MqnegImDeQg2kMLPvudTNvEbnRDpaCxWUHenEH5+\nTb8+UyG1e3V2su5J6p+Ghh88Y+zJyU4DWta3wAymLPW+gUEFXDAw5KdSg/8iD5kH\n6aMbd9l1Wp5WiErMsJOwoxhFVhwUK7MCXAXxHMWTuyu78RYYNhafPpd+Rq6dqy1d\nm5M7BlDU4Y2Y921xOSHGbNXbWbh5WHBT0QkV7IJunwiPYc5apJbee69DnQPGBF2I\ncLUBCAC9RsknEX9jPP4AB2/DxWdqTKb1/AmDpzNstEUtSy+zUKmErSNQeNUuso+n\nH2lFtb/A+s3nC2UPqbgz/SWN37Z9ip6hvsQX0etFvzYiXFfVDbknqmpi8AGt1Bkr\nFP9jGvF7gIT436icfjd8vQHHMIdEbEF+S46LfW8xibZEYUHLr5m8VO7dqqsIIwvr\nTJWEGjtRfaacqV3aWDzHbXodmYC3kQH9UjpdXE/9/5VxWtxDAFwsCaXiE9+EL69Z\ncJDdPfM0iVL3XgP22EFwC8C+Xha1PiSNOGFTIbv4aT5yLgPb7wwvgaw3semLF1vM\nzni3z+FNPREt+9itIhGPpxmIyAQ/ABEBAAH+BwMCl4RZZJs4k1bxqOmzniBxB2y8\nX4sDxyy60IOO3Ip2cOeyJ/qwEBdXxL2EjS6y5Vt3GTnQDz1IJqYiz9eRbZQnXUh5\nSk9ND9+mgxKYHDV52flh2FBTaOgqsKLnwLRUE12D1BQ9Ou5cYcgOIbCncftQTnWV\nk6hg9PDFZD/LD3IuKS9/DVYxKkYIJZ1KH446bQTYto+fnU0ajkLfED7wN4JquHq4\nYS+L7h8GetOCyRsh8zDIOPF5jZYCSzDe8Q03ktozOE2+YV/A3Z/0Uub8/Qecl5u3\n1qURXeIC6oGRDVXegZu3491IG+UTzjaBDPu+Xk4zad7mGjBQ9vNuKZfNNg/nHOWL\nNHRCXbkQVpXCA+v+dNYov+bqKe1QpF/2jvU0Bi32HLG8gfmRwD0WaAnbvOLTYXPl\ndRNDn7ixfOC0uHT6epPlZxsRja5TXlFl5qSuAX2zQfFdb+pRPztNKer6iMNz3itz\nh2B/gcTwqXloy4Zq9wqxcXLV1qmoMJ5YzRcd4otRM7FhZD0LxBAOANdmPSmRHbf6\nSXyXgvK4KDwRB52uIYJzWyIi7LiU3qTbV0GgptgHeVLwgbA9ioPwIu85+XQEh/eE\nEjNhz0CdtCqmYY3+SjNOZ8K59LgWCuv/YkRkmwDimEz5gUlpayoOYib4J4wIx6OY\n+fmI+gByS+UN3v+ml7l909c2W8w0dwR8+7P1XGBkaiVROTM2hdnHMflWm2548WBx\na6tbdCQPUDra/5O/pCdefc44hwD08liutq9kF29sMwONZy0P0dlyFZq4LTLmQqs0\nq93GZRjoI+uit9oTLGCtJ99aiSIx97KfkKx5wLnE4DruQlqPWnxV5Yfn9RZ6j8V2\nRQjL8f5RbiBtkkCl3r+nEdQIeMRy+8xxO9dTYTlaCNgfd92LdQrwzKe+GneUgKYg\nWo+AiQEfBBgBCAAJBQJdiHC1AhsMAAoJEL+qQtu6vXcZskIH+we2sHxPnWOfkXTu\npmZBU1ba8JLcrx+CMwTPkt7GMYpDplVOybn+AjMxB9JxST71mBw6rbwd2XLM1AmJ\nbvXVfmCtmC2TmrORqcoxEql+9tdgnSTfZfrltnYeVuEAvAxrMmD4S2Mb5zVWFl+q\ni69A0rdD8a36nOlJnZxfs57W1zfvl/rh+/RdybPwx9y0hSnRPSypis2dcwSyBD9+\nnlbn3QoybaUJxWvL+9MphCXZ4CuuhG0VPcmdH+LzOytDTtJnDNm+Ru4sokroDJXe\n5XlrK3+wtSNL4rfa/MQYyK8XsWRVR/a0BPeqZA4cPlHqCOSfUfaFbORVbPaQdq2a\nYYsMDlA=\n=ZwJf\n-----END PGP PRIVATE KEY BLOCK-----", + "Token": null, + "Signature": null + } +] diff --git a/pkg/pmapi/testdata/keyring_userKey b/pkg/pmapi/testdata/keyring_userKey new file mode 100644 index 00000000..976d2be2 --- /dev/null +++ b/pkg/pmapi/testdata/keyring_userKey @@ -0,0 +1,62 @@ +-----BEGIN PGP PRIVATE KEY BLOCK----- +Version: OpenPGP.js v4.4.5 +Comment: testpassphrase + +xcLYBFzGzhEBCADBxfqTFMqfQzT77A5tuuhPFwPq8dfC2evs8u1OvTqFbztY +5FOuSxzduyeDqQ1Fx6dKEOKgcYE8t1Uh4VSS7z6bTdY8j9yrL81kCVB46sE1 +OzStzyx/5l7OdH/pM4F+aKslnLvqlw0UeJr+UNizVtOCEUaNfVjPK3cc1ocx +v+36K4RnnyfEtjUW9gDZbhgaF02G5ILHmWmbgM7I+77gCd2wI0EdY9s/JZQ+ +VmkMFqoMdY9PyBchoOIPUkkGQi1SaF4IEzMaAUSbnCYkHHY/SbfDTcR46VGq +cXlkB1rq5xskaUQ9r+giCC/K4pc7bBkI1lQ7ADVuWvdrWnWapK0FO6CfABEB +AAEAB/0YPhPJ0phA/EWviN+16bmGVOZNaVapjt2zMMybWmrtEQv3OeWgO3nP +4cohRi/zaCBCphcm+dxbLhftW7AFi/9PVcR09436MB+oTCQFugpUWw+4TmA5 +BidxTpDxf4X2vH3rquQLBufWL6U7JlPeKAGL1xZ2aCq0DIeOk5D+xTjZizV2 +GIyQRVCLWb+LfDmvvcp3Y94X60KXdBAMuS1ZMKcY3Sl8VAXNB4KQsC/kByzf +6FCB097XZRYV7lvJJQ7+6Wisb3yVi8sEQx2sFm5fAp+0qi3a6zRTEp49r6Hr +gyWViH5zOOpA7DcNwx1Bwhi7GG0tak6EUnnKUNLfOupglcphBADmpXCgT4nc +uSBYTiZSVcB/ICCkTxVsHL1WcXtPK2Ikzussx2n9kb0rapvuC0YLipX9lUkQ +fyeC3jQJeCyN79AkDGkOfWaESueT2hM0Po+RwDgMibKn6yJ1zebz4Lc2J3C9 +oVFcAnql+9KyGsAPn03fyQzDnvhNnJvHJi4Hx8AWoQQA1xLoXeVBjRi0IjjU +E6Mqaq5RLEog4kXRp86VSSEGHBwyIYnDiM//gjseo/CXuVyHwL7UXitp8s1B +D1uE3APrhqUS66fD5pkF+z+RcSqiIv7I76NJ24Cdg38L6seGSjOHrq7/dEeG +K6WqfQUCEjta3yNSg7pXb2wn2WZqKIK+rz8EALZRuMXeql/FtO3Cjb0sv7oT +9dLP4cn1bskGRJ+Vok9lfCERbfXGccoAk3V+qSfpHgKxsebkRbUhf+trOGnw +tW+kBWo/5hYGQuN+A9JogSJViT+nuZyE+x1/rKswDFmlMSdf2GIDARWIV0gc +b1yOEwUmNBSthPcnFXvBr4BG3XTtNPTNLSJhcm9uMjEtM0BzYWRlbWJlLm9y +ZyIgPGFyb24yMS0zQHNhZGVtYmUub3JnPsLAdQQQAQgAHwUCXMbOEQYLCQcI +AwIEFQgKAgMWAgECGQECGwMCHgEACgkQZ/B4v2b2xB6XUgf/dHGRHimyMR78 +QYbEm2cuaEvOtq4a+J6Zv3P4VOWAbvkGWS9LDKSvVi60vq4oYOmF54HgPzur +nA4OtZDf0HKwQK45VZ7CYD693o70jkKPrAAJG3yTsbesfiS7RbFyGKzKJ7EL +nsUIJkfgm/SlKmXU/u8MOBO5Wg7/TcsS33sRWHl90j+9jbhqdl92R+vY/CwC +ieFkQA7/TDv1u+NAalH+Lpkd8AIuEcki+TAogZ7oi/SnofwnoB7BxRm+mIkp +ZZhIDSCaPOzLG8CSZ81d3HVHhqbf8dh0DFKFoUYyKdbOqIkNWWASf+c/ZEme +IWcekY8hqwf/raZ56tGM/bRwYPcotMfC1wRcxs4RAQgAsMb5/ELWmrfPy3ba +5qif+RXhGSbjitATNgHpoPUHrfTC7cn4JWHqehoXLAQpFAoKd+O/ZNpZozK9 +ilpqGUx05yMw06jNQEhYIbgIF4wzPpz02Lp6YeMwdF5LF+Rw83PHdHrA/wRV +/QjL04+kZnN+G5HmzMlhFY+oZSpL+Gp1bTXgtAVDkhCnMB5tP2VwULMGyJ+X +vRYxwTK2CrLjIVZv5n1VYY+caCowU6j/XFqvlCJj+G5oV+UhFOWffaMRXhOh +a64RrhqT1Np7wCLvLMP2wpys9xlMcLQJLqDNxqOTp504V7dm67ncC0fKUsT4 +m4oTktnxKPd6MU+4VYveaLCquwARAQABAAf4u9s7gpGErs1USxmDO9TlyGZK +aBlri8nMf3s+hOJCOo3cRaRHJBfdY6pu/baG6H6JTsWzeY4MHwr6N+dhVIEh +FPMa9EZAjagyc4GugxWGiMVTfU+2AEfdrdynhQKMgXSctnnNCdkRuX0nwqb3 +nlupm1hsz2ze4+Wg0BKSLS0FQdoUbITdJUR69OHr4dNJVHWYI0JSBx4SdhV3 +y9163dDvmc+lW9AEaD53vyZWfzCHZxsR/gI32VmT0z5gn1t8w9AOdXo2lA1H +bf7wh4/qCyujGu64ToZtiEny/GCyM6PofLtiZuJNLw3s/y+B2tKv22aTJ760 ++Gib1xB9WcWjKyrxBADoeCyq+nHGrl0CwOkmjanlFymgo7mnBOXuiFOvGrKk +M1meMU1TI4TEBWkVnDVMcSejgjAf/bX1dtouba1tMAMu7DlaV/0EwbSADRel +RSqEbIzIOys+y9TY/BMI/uCKNyEKHvu1KUXADb+CBpdBpCfMBWDANFlo9xLz +Ajcmu2dyawQAwquwC0VXQcvzfs+Hd5au5XvHdm1KidOiAdu6PH1SrOgenIN4 +lkEjHrJD9jmloO2/GVcxDBB2pmf0B4HEg7DuY9LXBrksP5eSbbRc5+UH1HUv +u82AqQnfNKTd/jae+lLwaOS++ohtwMkkD6W0LdWnHPjyyXg4Oi9zPID3asRu +3PED/3CYyjl6S8GTMY4FNH7Yxu9+NV2xpKE92Hf8K/hnYlmSSVKDCEeOJtLt +BkkcSqY6liCNSMmJdVyAF2GrR+zmDac7UQRssf57oOWtSsGozt0aqJXuspMT +6aB+P1UhZ8Ly9rWZNiJ0jwyfnQNOLCYDaqjFmiSpqrNnJ2Q1Xge3+k80P9DC +wF8EGAEIAAkFAlzGzhECGwwACgkQZ/B4v2b2xB5wlwgAjZA1zdv5irFjyWVo +4/itONtyO1NbdpyYpcct7vD0oV+a4wahQP0J3Kk1GhZ5tvAoZF/jakQQOM5o +GjUYpXAGnr09Mv9EiQ2pDwXc2yq0WfXnGxNrpzOqdtV+IqY9NYkl55Tme7x+ +WRvrkPSUeUsyEGvxwR1stdv8eg9jUmxdl8Io3PYoFJJlrM/6aXeC1r3KOj7q +XAnR0XHJ+QBSNKCWLlQv5hui9BKfcLiVKFK/dNhs82nRyhPr4sWFw6MTqdAK +4zkn7l0jmy6Evi1AiiGPiHPnxeNErnofOIEh4REQj00deZADHrixTLtx2FuR +uaSC3IcBmBsj1fNb4eYXElILjQ== +=fMOl +-----END PGP PRIVATE KEY BLOCK----- \ No newline at end of file diff --git a/pkg/pmapi/testdata/keyring_userKey_JSON b/pkg/pmapi/testdata/keyring_userKey_JSON new file mode 100644 index 00000000..3dda93bc --- /dev/null +++ b/pkg/pmapi/testdata/keyring_userKey_JSON @@ -0,0 +1,10 @@ +[ + { + "ID": "IlnTbqicN-2HfUGIn-ki8bqZfLqNj5ErUB0z24Qx5g-4NvrrIc6GLvEpj2EPfwGDv28aKYVRRrSgEFhR_zhlkA==", + "Version": 3, + "PrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----\r\nVersion: OpenPGP.js v0.7.1\r\nComment: http://openpgpjs.org\r\n\r\nxcMGBFRJbc0BCAC0mMLZPDBbtSCWvxwmOfXfJkE2+ssM3ux21LhD/bPiWefE\nWSHlCjJ8PqPHy7snSiUuxuj3f9AvXPvg+mjGLBwu1/QsnSP24sl3qD2onl39\nvPiLJXUqZs20ZRgnvX70gjkgEzMFBxINiy2MTIG+4RU8QA7y8KzWev0btqKi\nMeVa+GLEHhgZ2KPOn4Jv1q4bI9hV0C9NUe2tTXS6/Vv3vbCY7lRR0kbJ65T5\nc8CmpqJuASIJNrSXM/Q3NnnsY4kBYH0s5d2FgbASQvzrjuC2rngUg0EoPsrb\nDEVRA2/BCJonw7aASiNCrSP92lkZdtYlax/pcoE/mQ4WSwySFmcFT7yFABEB\nAAH+CQMIvzcDReuJkc9gnxAkfgmnkBFwRQrqT/4UAPOF8WGVo0uNvDo7Snlk\nqWsJS+54+/Xx6Jur/PdBWeEu+6+6GnppYuvsaT0D0nFdFhF6pjng+02IOxfG\nqlYXYcW4hRru3BfvJlSvU2LL/Z/ooBnw3T5vqd0eFHKrvabUuwf0x3+K/sru\nFp24rl2PU+bzQlUgKpWzKDmO+0RdKQ6KVCyCDMIXaAkALwNffAvYxI0wnb2y\nWAV/bGn1ODnszOYPk3pEMR6kKSxLLaO69kYx4eTERFyJ+1puAxEPCk3Cfeif\nyDWi4rU03YB16XH7hQLSFl61SKeIYlkKmkO5Hk1ybi/BhvOGBPVeGGbxWnwI\n46G8DfBHW0+uvD5cAQtk2d/q3Ge1I+DIyvuRCcSu0XSBNv/Bkpp4IbAUPBaW\nTIvf5p9oxw+AjrMtTtcdSiee1S6CvMMaHhVD7SI6qGA8GqwaXueeLuEXa0Ok\nBWlehx8wibMi4a9fLcQZtzJkmGhR1WzXcJfiEg32srILwIzPQYxuFdZZ2elb\ngYp/bMEIp4LKhi43IyM6peCDHDzEba8NuOSd0heEqFIm0vlXujMhkyMUvDBv\nH0V5On4aMuw/aSEKcAdbazppOru/W1ndyFa5ZHQIC19g72ZaDVyYjPyvNgOV\nAFqO4o3IbC5z31zMlTtMbAq2RG9svwUVejn0tmF6UPluTe0U1NuXFpLK6TCH\nwqocLz4ecptfJQulpYjClVLgzaYGDuKwQpIwPWg5G/DtKSCGNtEkfqB3aemH\nV5xmoYm1v5CQZAEvvsrLA6jxCk9lzqYV8QMivWNXUG+mneIEM35G0HOPzXca\nLLyB+N8Zxioc9DPGfdbcxXuVgOKRepbkq4xv1pUpMQ4BUmlkejDRSP+5SIR3\niEthg+FU6GRSQbORE6nhrKjGBk8fpNpozQZVc2VySUTCwHIEEAEIACYFAlRJ\nbc8GCwkIBwMCCRA+tiWe3yHfJAQVCAIKAxYCAQIbAwIeAQAA9J0H/RLR/Uwt\nCakrPKtfeGaNuOI45SRTNxM8TklC6tM28sJSzkX8qKPzvI1PxyLhs/i0/fCQ\n7Z5bU6n41oLuqUt2S9vy+ABlChKAeziOqCHUcMzHOtbKiPkKW88aO687nx+A\nol2XOnMTkVIC+edMUgnKp6tKtZnbO4ea6Cg88TFuli4hLHNXTfCECswuxHOc\nAO1OKDRrCd08iPI5CLNCIV60QnduitE1vF6ehgrH25Vl6LEdd8vPVlTYAvsa\n6ySk2RIrHNLUZZ3iII3MBFL8HyINp/XA1BQP+QbH801uSLq8agxM4iFT9C+O\nD147SawUGhjD5RG7T+YtqItzgA1V9l277EXHwwYEVEltzwEIAJD57uX6bOc4\nTgf3utfL/4hdyoqIMVHkYQOvE27wPsZxX08QsdlaNeGji9Ap2ifIDuckUqn6\nJi9jtZDKtOzdTBm6rnG5nPmkn6BJXPhnecQRP8N0XBISnAGmE4t+bxtts5Wb\nqeMdxJYqMiGqzrLBRJEIDTcg3+QF2Y3RywOqlcXqgG/xX++PsvR1Jiz0rEVP\nTcBc7ytyb/Av7mx1S802HRYGJHOFtVLoPTrtPCvv+DRDK8JzxQW2XSQLlI0M\n9s1tmYhCogYIIqKx9qOTd5mFJ1hJlL6i9xDkvE21qPFASFtww5tiYmUfFaxI\nLwbXPZlQ1I/8fuaUdOxctQ+g40ZgHPcAEQEAAf4JAwgdUg8ubE2BT2DITBD+\nXFgjrnUlQBilbN8/do/36KHuImSPO/GGLzKh4+oXxrvLc5fQLjeO+bzeen4u\nCOCBRO0hG7KpJPhQ6+T02uEF6LegE1sEz5hp6BpKUdPZ1+8799Rylb5kubC5\nIKnLqqpGDbH3hIsmSV3CG/ESkaGMLc/K0ZPt1JRWtUQ9GesXT0v6fdM5GB/L\ncZWFdDoYgZAw5BtymE44knIodfDAYJ4DHnPCh/oilWe1qVTQcNMdtkpBgkuo\nTHecqEmiODQz5EX8pVmS596XsnPO299Lo3TbaHUQo7EC6Au1Au9+b5hC1pDa\nFVCLcproi/Cgch0B/NOCFkVLYmp6BEljRj2dSZRWbO0vgl9kFmJEeiiH41+k\nEAI6PASSKZs3BYLFc2I8mBkcvt90kg4MTBjreuk0uWf1hdH2Rv8zprH4h5Uh\ngjx5nUDX8WXyeLxTU5EBKry+A2DIe0Gm0/waxp6lBlUl+7ra28KYEoHm8Nq/\nN9FCuEhFkFgw6EwUp7jsrFcqBKvmni6jyplm+mJXi3CK+IiNcqub4XPnBI97\nlR19fupB/Y6M7yEaxIM8fTQXmP+x/fe8zRphdo+7o+pJQ3hk5LrrNPK8GEZ6\nDLDOHjZzROhOgBvWtbxRktHk+f5YpuQL+xWd33IV1xYSSHuoAm0Zwt0QJxBs\noFBwJEq1NWM4FxXJBogvzV7KFhl/hXgtvx+GaMv3y8gucj+gE89xVv0XBXjl\n5dy5/PgCI0Id+KAFHyKpJA0N0h8O4xdJoNyIBAwDZ8LHt0vlnLGwcJFR9X7/\nPfWe0PFtC3d7cYY3RopDhnRP7MZs1Wo9nZ4IvlXoEsE2nPkWcns+Wv5Yaewr\ns2ra9ZIK7IIJhqKKgmQtCeiXyFwTq+kfunDnxeCavuWL3HuLKIOZf7P9vXXt\nXgEir9rCwF8EGAEIABMFAlRJbdIJED62JZ7fId8kAhsMAAD+LAf+KT1EpkwH\n0ivTHmYako+6qG6DCtzd3TibWw51cmbY20Ph13NIS/MfBo828S9SXm/sVUzN\n/r7qZgZYfI0/j57tG3BguVGm53qya4bINKyi1RjK6aKo/rrzRkh5ZVD5rVNO\nE2zzvyYAnLUWG9AV1OYDxcgLrXqEMWlqZAo+Wmg7VrTBmdCGs/BPvscNgQRr\n6Gpjgmv9ru6LjRL7vFhEcov/tkBLj+CtaWWFTd1s2vBLOs4rCsD9TT/23vfw\nCnokvvVjKYN5oviy61yhpqF1rWlOsxZ4+2sKW3Pq7JLBtmzsZegTONfcQAf7\nqqGRQm3MxoTdgQUShAwbNwNNQR9cInfMnA==\r\n=2wIY\r\n-----END PGP PRIVATE KEY BLOCK-----\r\n", + "Fingerprint": "c93f767df53b0ca8395cfde90483475164ec6353", + "Activation": null, + "Primary": 1 + } +] diff --git a/pkg/pmapi/testdata/routes/HTTP_200.json b/pkg/pmapi/testdata/routes/HTTP_200.json new file mode 100644 index 00000000..ebc8f3a6 --- /dev/null +++ b/pkg/pmapi/testdata/routes/HTTP_200.json @@ -0,0 +1,3 @@ +{ + "Code": 1000 +} diff --git a/pkg/pmapi/testdata/routes/HTTP_401.json b/pkg/pmapi/testdata/routes/HTTP_401.json new file mode 100644 index 00000000..6fbbf86f --- /dev/null +++ b/pkg/pmapi/testdata/routes/HTTP_401.json @@ -0,0 +1,4 @@ +{ + "Code": 5000, + "Error": "Status unauthorized" +} diff --git a/pkg/pmapi/testdata/routes/HTTP_402.json b/pkg/pmapi/testdata/routes/HTTP_402.json new file mode 100644 index 00000000..2b608112 --- /dev/null +++ b/pkg/pmapi/testdata/routes/HTTP_402.json @@ -0,0 +1,4 @@ +{ + "Code": 5000, + "Error": "Status payment required" +} diff --git a/pkg/pmapi/testdata/routes/addresses/get_response.json b/pkg/pmapi/testdata/routes/addresses/get_response.json new file mode 100644 index 00000000..6a5b1c00 --- /dev/null +++ b/pkg/pmapi/testdata/routes/addresses/get_response.json @@ -0,0 +1,43 @@ +{ + "Code": 1000, + "Addresses": [ + { + "ID": "qmhrlFY24BhSHiFplF0B7G_cMVLi1sokaWIhfNaee6dRtdIZPYnqgI4-MpAb8h3JhOOykKv8ZsuTH8X_SrUZSg==", + "DomainID": "l8vWAXHBQmv0u7OVtPbcqMa4iwQaBqowINSQjPrxAr-Da8fVPKUkUcqAq30_BCxj1X0nW70HQRmAa-rIvzmKUA==", + "Email": "jason@protonmail.com", + "Send": 1, + "Receive": 1, + "Status": 1, + "Type": 1, + "Order": 1, + "DisplayName": "D L'u, P.D. \u5b9a\u8d85", + "Signature": "hi there", + "HasKeys": 1, + "Keys": [ + { + "ID": "IlnTbqicN-2HfUGIn-ki8bqZfLqNj5ErUB0z24Qx5g-4NvrrIc6GLvEpj2EPfwGDv28aKYVRRrSgEFhR_zhlkA==", + "Version": 3, + "Flags": 3, + "PrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----\r\nVersion: OpenPGP.js v0.7.1\r\nComment: http://openpgpjs.org\r\n\r\n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\nqqGRQm3MxoTdgQUShAwbNwNNQR9cInfMnA==\r\n=2wIY\r\n-----END PGP PRIVATE KEY BLOCK-----\r\n", + "Activation": null, + "Primary": 1, + "Token": null + } + ] + }, + { + "ID": "_pm5NXefHCdfqXuHiQ_zcOsfC4rGaCwV4lxuJt5qCmZBh4RiQ0k5iA8wLLaphTWHWAETz-WDqjLDRXNWftciXw==", + "DomainID": "l8vWAXHBQmv0u7OVtPbcqMa4iwQaBqowINSQjPrxAr-Da8fVPKUkUcqAq30_BCxj1X0nW70HQRmAa-rIvzmKUA==", + "Email": "hi@protonmail.dev", + "Send": 1, + "Receive": 0, + "Status": 0, + "Type": 2, + "Order": 2, + "DisplayName": "hi", + "Signature": "hi there", + "HasKeys": 0, + "Keys": [] + } + ] +} \ No newline at end of file diff --git a/pkg/pmapi/testdata/routes/auth/2fa/post_401_bad_password.json b/pkg/pmapi/testdata/routes/auth/2fa/post_401_bad_password.json new file mode 100644 index 00000000..2b384103 --- /dev/null +++ b/pkg/pmapi/testdata/routes/auth/2fa/post_401_bad_password.json @@ -0,0 +1,5 @@ +{ + "Code": 8002, + "Error": "Incorrect login credentials.", + "Details": {} +} diff --git a/pkg/pmapi/testdata/routes/auth/2fa/post_422_bad_password.json b/pkg/pmapi/testdata/routes/auth/2fa/post_422_bad_password.json new file mode 100644 index 00000000..17330259 --- /dev/null +++ b/pkg/pmapi/testdata/routes/auth/2fa/post_422_bad_password.json @@ -0,0 +1,5 @@ +{ + "Code": 8002, + "Error": "Incorrect login credentials. Please try again", + "Details": {} +} diff --git a/pkg/pmapi/testdata/routes/auth/2fa/post_response.json b/pkg/pmapi/testdata/routes/auth/2fa/post_response.json new file mode 100644 index 00000000..e58daf78 --- /dev/null +++ b/pkg/pmapi/testdata/routes/auth/2fa/post_response.json @@ -0,0 +1,4 @@ +{ + "Code": 1000, + "Scope": "full mail payments reset keys" +} \ No newline at end of file diff --git a/pkg/pmapi/testdata/routes/auth/delete_response.json b/pkg/pmapi/testdata/routes/auth/delete_response.json new file mode 100644 index 00000000..fd8e6930 --- /dev/null +++ b/pkg/pmapi/testdata/routes/auth/delete_response.json @@ -0,0 +1,4 @@ +{ + "Code": 1000 +} + diff --git a/pkg/pmapi/testdata/routes/auth/enc_priv_key.asc b/pkg/pmapi/testdata/routes/auth/enc_priv_key.asc new file mode 100644 index 00000000..4c59538e --- /dev/null +++ b/pkg/pmapi/testdata/routes/auth/enc_priv_key.asc @@ -0,0 +1,65 @@ +-----BEGIN PGP PRIVATE KEY BLOCK----- +Version: OpenPGP.js v0.7.1 +Comment: http://openpgpjs.org + +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 +qqGRQm3MxoTdgQUShAwbNwNNQR9cInfMnA== +=2wIY +-----END PGP PRIVATE KEY BLOCK----- + + diff --git a/pkg/pmapi/testdata/routes/auth/encrypted_access_token.asc b/pkg/pmapi/testdata/routes/auth/encrypted_access_token.asc new file mode 100644 index 00000000..756a5053 --- /dev/null +++ b/pkg/pmapi/testdata/routes/auth/encrypted_access_token.asc @@ -0,0 +1,15 @@ +-----BEGIN PGP MESSAGE----- +Version: OpenPGP.js v1.2.0 +Comment: http://openpgpjs.org + +wcBMA0fcZ7XLgmf2AQf/RxDfA7g85KzH4371D/jx6deJIXPOWAqgTlGQMsTt +yg4ny3phSC2An/bUXNEBm8UMXqqtS7O+S8n1GjkDrCOkxyC+HugOFQwtybzI +eRX0X0qqvR6ry940SNGjPfJJ4Z0FYSLJtT8YxqO38t38WAYV1j9mBBVPMPJF +r7cQXxEcQAd6NZWF1Cf5Ajuum/zFjbA10Ksbi1tC4fsdtHcS94h1GCfsdNQi +xxbAuoyNYX2wsc6WX8IcmDNn564ZoHfvf2tX4Csf+2czByyOPtfyCn1aee51 +I40/I+65w8NfYEfzu7pbUcdo041Xg3lOhDNcuX/zANNw6zEWbE+12G5KVvwC +NNJgARWnwnOKtov2d73wGqNawn21SzA+zEd2mAPv1LPPIupW+0xOUSp5muov +aLEjcIuZeu+vyhXGZxIgoY4Bw8XCO9uWKZuzmqp+AOIP+kSi5aWnOaDFIOq0 +B3KtZ33bMZeX +=mig5 +-----END PGP MESSAGE----- diff --git a/pkg/pmapi/testdata/routes/auth/info/post_response.json b/pkg/pmapi/testdata/routes/auth/info/post_response.json new file mode 100644 index 00000000..5c637117 --- /dev/null +++ b/pkg/pmapi/testdata/routes/auth/info/post_response.json @@ -0,0 +1,13 @@ +{ + "Code": 1000, + "Modulus": "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\nW2z5HBi8RvsfYzZTS7qBaUxxPhsfHJFZpu3Kd6s1JafNrCCH9rfvPLrfuqocxWPgWDH2R8neK7PkNvjxto9TStuY5z7jAzWRvFWN9cQhAKkdWgy0JY6ywVn22+HFpF4cYesHrqFIKUPDMSSIlWjBVmEJZ/MusD44ZT29xcPrOqeZvwtCffKtGAIjLYPZIEbZKnDM1Dm3q2K/xS5h+xdhjnndhsrkwm9U9oyA2wxzSXFL+pdfj2fOdRwuR5nW0J2NFrq3kJjkRmpO/Genq1UW+TEknIWAb6VzJJJA244K/H8cnSx2+nSNZO3bbo6Ys228ruV9A8m6DhxmS+bihN3ttQ==\n-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwl4EARYIABAFAlwB1j0JEDUFhcTpUY8mAAD8CgEAnsFnF4cF0uSHKkXa1GIa\nGO86yMV4zDZEZcDSJo0fgr8A/AlupGN9EdHlsrZLmTA1vhIx+rOgxdEff28N\nkvNM7qIK\n=q6vu\n-----END PGP SIGNATURE-----\n", + "ServerEphemeral": "5tfigcLKoM0DPWYB+EqYE7QlqsiT63iOVlO5ZX0lTMEILSsrRdVCYrN8L3zkinsAjUZ/cx5wIS7N05k66uZb+ZE3lFOJS2s1BkqLvCrGxYL0e3n5YAnzHYlvCCJKXw/sK57ntfF1OOoblBXX6dw5LjeeDglEep2/DaE0TjD8WUpq4Ls2HlQGn9wrC7dFO2lJXsMhRffxKghiOsdvCLXDmwXginzn/LFezA8KrDsWOBSEGntwpg3s1xFj5h8BqtRHvC0igmoscqgw+3GCMTJ0NZAQ/L+5aJ/0ccL0WBK208ltCNl+/X6Sz0kpyvOP4RqFJhC1auVDJ9AjZQYSYZ1NEQ==", + "Version": 4, + "Salt": "yKlc5/CvObfoiw==", + "SRPSession": "9b2946bbd9055f17c34940abdce0c3d3", + "TwoFactor": 0, + "2FA": { + "TOTP": 1, + "U2F": null + } +} diff --git a/pkg/pmapi/testdata/routes/auth/post_response.json b/pkg/pmapi/testdata/routes/auth/post_response.json new file mode 100644 index 00000000..71c9fb6d --- /dev/null +++ b/pkg/pmapi/testdata/routes/auth/post_response.json @@ -0,0 +1,11 @@ +{ + "Code": 1000, + "AccessToken": "de0423049b44243afeec7d9c1d99be7b46da1e8a", + "ExpiresIn": 86400, + "TokenType": "Bearer", + "Scope": "full mail payments reset keys", + "UID": "729ad6012421d67ad26950dc898bebe3a6e3caa2", + "RefreshToken": "a49b98256745bb497bec20e9b55f5de16f01fb52", + "EventID": "NcKPtU5eMNPMrDkIMbEJrgMtC9yQ7Xc5ZBT-tB3UtV1rZ324RWfCIdBI758q0UnsfywS8CkNenIQlWLIX_dUng==", + "ServerProof": "jZaSvHT6HKSZ2Vl41Q5Po/23KVqEagw1nmgwBDcLLgxzU0QMxVHpZBdiujknpVVV3kAZ9QALiHieoo8yGELpPyrWrIwP38Dw4iT9UL1tprPj2pAhJ3ZsPjQR1peamS1YiJXIbky/FraXEjD50Q/3bSAPP1B2LWJN6s+lrba//Dsp8y6Vp4sEQ2BShrkBTwY3U9/bJ0oaE1Z/j5lN9I6JNmVyFGNW76icU7SfSnYdSiCd//FgkfVtyexYRmNgg9UxbAz7M7wjCyrQGuTVSF5/YzdUp+VxBVosaEh6H0AH4PfP49o85vrgMYBim0ixjm7Eh3xJTuqgbxjzutrS08A4mw==" +} \ No newline at end of file diff --git a/pkg/pmapi/testdata/routes/auth/refresh/post_resp_has_uid.json b/pkg/pmapi/testdata/routes/auth/refresh/post_resp_has_uid.json new file mode 100644 index 00000000..cdf3fb50 --- /dev/null +++ b/pkg/pmapi/testdata/routes/auth/refresh/post_resp_has_uid.json @@ -0,0 +1,10 @@ +{ + "Code": 1000, + "AccessToken": "de0423049b44243afeec7d9c1d99be7b46da1e8a", + "ExpiresIn": 360000, + "TokenType": "Bearer", + "Uid": "differentUID", + "UID": "differentUID", + "Scope": "full mail payments reset keys", + "RefreshToken": "b894b4c4f20003f12d486900d8b88c7d68e67235" +} \ No newline at end of file diff --git a/pkg/pmapi/testdata/routes/auth/refresh/post_response.json b/pkg/pmapi/testdata/routes/auth/refresh/post_response.json new file mode 100644 index 00000000..cf7a0fac --- /dev/null +++ b/pkg/pmapi/testdata/routes/auth/refresh/post_response.json @@ -0,0 +1,8 @@ +{ + "Code": 1000, + "AccessToken": "de0423049b44243afeec7d9c1d99be7b46da1e8a", + "ExpiresIn": 360000, + "TokenType": "Bearer", + "Scope": "full mail payments reset keys", + "RefreshToken": "b894b4c4f20003f12d486900d8b88c7d68e67235" +} \ No newline at end of file diff --git a/pkg/pmapi/testdata/routes/contacts/put_response.json b/pkg/pmapi/testdata/routes/contacts/put_response.json new file mode 100644 index 00000000..3dc9da17 --- /dev/null +++ b/pkg/pmapi/testdata/routes/contacts/put_response.json @@ -0,0 +1,21 @@ +{ + "Code": 1000, + "Contact": { + "ID": "l4PrVkmDsIIDba9aln829uwPK0nnyWZHnFtrsyb7CJsYgrD6JTVTuuoaVmaANfO2jIVxzZ2vtbt74rznGjjwFQ==", + "Name": "Bob", + "UID": "proton-web-cd974706-5cde-0e53-e131-c49c88a92ece", + "Size": 303, + "CreateTime": 1517416603, + "ModifyTime": 1517416656, + "ContactEmails": [ + { + "ID": "14n6vuf1zbeo3zsYzgV471S6xJ9gzl7-VZ8tcOTQq6ifBlNEre0SUdUM7sXh6e2Q_4NhJZaU9c7jLdB1HCV6dA==", + "Name": "Bob", + "Email": "bob.changed.tester@protonmail.com", + "Defaults": 1, + "Order": 1, + "ContactID": "l4PrVkmDsIIDba9aln829uwPK0nnyWZHnFtrsyb7CJsYgrD6JTVTuuoaVmaANfO2jIVxzZ2vtbt74rznGjjwFQ==" + } + ] + } +} diff --git a/pkg/pmapi/testdata/routes/keys/salts/get_response.json b/pkg/pmapi/testdata/routes/keys/salts/get_response.json new file mode 100644 index 00000000..71d29646 --- /dev/null +++ b/pkg/pmapi/testdata/routes/keys/salts/get_response.json @@ -0,0 +1,13 @@ +{ + "Code": 1000, + "KeySalts": [ + { + "ID": "IlnTbqicN-2HfUGIn-ki8bqZfLqNj5ErUB0z24Qx5g-4NvrrIc6GLvEpj2EPfwGDv28aKYVRRrSgEFhR_zhlkA==", + "KeySalt": "abc" + }, + { + "ID": "_pm5NXefHCdfqXuHiQ_zcOsfC4rGaCwV4lxuJt5qCmZBh4RiQ0k5iA8wLLaphTWHWAETz-WDqjLDRXNWftciXw==", + "KeySalt": "abc" + } + ] +} diff --git a/pkg/pmapi/testdata/routes/messages/get_response.json b/pkg/pmapi/testdata/routes/messages/get_response.json new file mode 100644 index 00000000..014fb3b0 --- /dev/null +++ b/pkg/pmapi/testdata/routes/messages/get_response.json @@ -0,0 +1,55 @@ +{ + "Code": 1000, + "Message": { + "ID": "AeUizgtA3H44qRgcr-HdBApwLiUhlQg5kB81mg_QalWotmQJIHep9OScWIo7Wu9pnYxM4RqQxJnr3BE4kh4y_Q==", + "Order": 851654, + "ConversationID": "FK4MKKIVJqOC9Pg_sAxCjNWf8PM9yGzrXO3eXq8sk5RJB6HtaRBNUEcnvJBrQVPAtrDSoTNq4Du3FpqIxyMhHQ==", + "Subject": "Welcome to ProtonMail!", + "Unread": 0, + "SenderAddress": "notify@protonmail.com", + "SenderName": "ProtonMail", + "Sender": { + "Address": "notify@protonmail.com", + "Name": "ProtonMail" + }, + "ToList": [ + { + "Address": "apple@protonmail.com", + "Name": "" + } + ], + "Time": 1414098386, + "Size": 4398, + "NumAttachments": 1, + "ExpirationTime": 0, + "Flags": 1, + "SpamScore": 0, + "AddressID": "QMJs2dzTx7uqpH5PNgIzjULywU4gO9uMBhEMVFOAVJOoUml54gC0CCHtW9qYwzH-zYbZwMv3MFYncPjW1Usq7Q==", + "CCList": [], + "BCCList": [], + "ExternalID": null, + "Body": "
    jeej saas

    Sent from ProtonMail, encrypted email based in Switzerland.

    ", + "Header": "Content-Description: an awesome email\r\nX-Mailer: CroutonMail\r\n", + "ReplyTo": { + "Address": "notify@protonmail.com", + "Name": "ProtonMail" + }, + "Attachments": [ + { + "ID": "y6uKIlc2HdoHPAwPSrvf7dXoZNMYvBgxshYUN67cY5DJjL2O8NYewuvGHcYvCfd8LpEoAI_GdymO0Jr0mHlsEw==", + "Name": "croutonmail.txt", + "Size": 77, + "MIMEType": "text/plain", + "KeyPackets": "wcBMA0fcZ7XLgmf2AQgAiRsOlnm1kSB4/lr7tYe6pBsRGn10GqwUhrwU5PMKOHdCgnO12jO3y3CzP0Yl/jGhAYja9wLDqH8X0sk3tY32u4Sb1Qe5IuzggAiCa4dwOJj5gEFMTHMzjIMPHR7A70XqUxMhmILye8V4KRm/j4c1sxbzA1rM3lYBumQuB5l/ck0Kgt4ZqxHVXHK5Q1l65FHhSXRj8qnunasHa30TYNzP8nmBA8BinnJxpiQ7FGc2umnUhgkFtjm5ixu9vyjr9ukwDTbwAXXfmY+o7tK7kqIXJcmTL6k2UeC6Mz1AagQtRCRtU+bv/3zGojq/trZo9lom3naIeQYa36Ketmcpj2Qwjg==", + "Headers": { + "content-description": "You'll never believe what's in this text file" + }, + "MessageID": "h3CD-DT7rLoAw1vmpcajvIPAl-wwDfXR2MHtWID3wuQURDBKTiGUAwd6E2WBbS44QQKeXImW-axm6X0hAfcVCA==" + } + ], + "LabelIDs": [ + "10", + "0" + ] + } +} \ No newline at end of file diff --git a/pkg/pmapi/testdata/routes/messages/label/put_response.json b/pkg/pmapi/testdata/routes/messages/label/put_response.json new file mode 100644 index 00000000..564fffff --- /dev/null +++ b/pkg/pmapi/testdata/routes/messages/label/put_response.json @@ -0,0 +1,17 @@ +{ + "Code": 1001, + "Responses": [ + { + "ID": "LKJLalkfejl==", + "Response": { + "Code": 1000 + } + }, + { + "ID": "ASia83sJaL==", + "Response": { + "Code": 1000 + } + } + ] +} diff --git a/pkg/pmapi/testdata/routes/users/get_response.json b/pkg/pmapi/testdata/routes/users/get_response.json new file mode 100644 index 00000000..e391a09b --- /dev/null +++ b/pkg/pmapi/testdata/routes/users/get_response.json @@ -0,0 +1,27 @@ +{ + "Code": 1000, + "User": { + "ID": "MJLke8kWh1BBvG95JBIrZvzpgsZ94hNNgjNHVyhXMiv4g9cn6SgvqiIFR5cigpml2LD_iUk_3DkV29oojTt3eA==", + "Name": "jason", + "UsedSpace": 96691332, + "Currency": "USD", + "Credit": 0, + "MaxSpace": 10737418240, + "MaxUpload": 26214400, + "Role": 2, + "Private": 1, + "Subscribed": 1, + "Services": 1, + "Delinquent": 0, + "Keys": [ + { + "ID": "IlnTbqicN-2HfUGIn-ki8bqZfLqNj5ErUB0z24Qx5g-4NvrrIc6GLvEpj2EPfwGDv28aKYVRRrSgEFhR_zhlkA==", + "Version": 3, + "PrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----\r\nVersion: OpenPGP.js v0.7.1\r\nComment: http://openpgpjs.org\r\n\r\nxcMGBFRJbc0BCAC0mMLZPDBbtSCWvxwmOfXfJkE2+ssM3ux21LhD/bPiWefE\nWSHlCjJ8PqPHy7snSiUuxuj3f9AvXPvg+mjGLBwu1/QsnSP24sl3qD2onl39\nvPiLJXUqZs20ZRgnvX70gjkgEzMFBxINiy2MTIG+4RU8QA7y8KzWev0btqKi\nMeVa+GLEHhgZ2KPOn4Jv1q4bI9hV0C9NUe2tTXS6/Vv3vbCY7lRR0kbJ65T5\nc8CmpqJuASIJNrSXM/Q3NnnsY4kBYH0s5d2FgbASQvzrjuC2rngUg0EoPsrb\nDEVRA2/BCJonw7aASiNCrSP92lkZdtYlax/pcoE/mQ4WSwySFmcFT7yFABEB\nAAH+CQMIvzcDReuJkc9gnxAkfgmnkBFwRQrqT/4UAPOF8WGVo0uNvDo7Snlk\nqWsJS+54+/Xx6Jur/PdBWeEu+6+6GnppYuvsaT0D0nFdFhF6pjng+02IOxfG\nqlYXYcW4hRru3BfvJlSvU2LL/Z/ooBnw3T5vqd0eFHKrvabUuwf0x3+K/sru\nFp24rl2PU+bzQlUgKpWzKDmO+0RdKQ6KVCyCDMIXaAkALwNffAvYxI0wnb2y\nWAV/bGn1ODnszOYPk3pEMR6kKSxLLaO69kYx4eTERFyJ+1puAxEPCk3Cfeif\nyDWi4rU03YB16XH7hQLSFl61SKeIYlkKmkO5Hk1ybi/BhvOGBPVeGGbxWnwI\n46G8DfBHW0+uvD5cAQtk2d/q3Ge1I+DIyvuRCcSu0XSBNv/Bkpp4IbAUPBaW\nTIvf5p9oxw+AjrMtTtcdSiee1S6CvMMaHhVD7SI6qGA8GqwaXueeLuEXa0Ok\nBWlehx8wibMi4a9fLcQZtzJkmGhR1WzXcJfiEg32srILwIzPQYxuFdZZ2elb\ngYp/bMEIp4LKhi43IyM6peCDHDzEba8NuOSd0heEqFIm0vlXujMhkyMUvDBv\nH0V5On4aMuw/aSEKcAdbazppOru/W1ndyFa5ZHQIC19g72ZaDVyYjPyvNgOV\nAFqO4o3IbC5z31zMlTtMbAq2RG9svwUVejn0tmF6UPluTe0U1NuXFpLK6TCH\nwqocLz4ecptfJQulpYjClVLgzaYGDuKwQpIwPWg5G/DtKSCGNtEkfqB3aemH\nV5xmoYm1v5CQZAEvvsrLA6jxCk9lzqYV8QMivWNXUG+mneIEM35G0HOPzXca\nLLyB+N8Zxioc9DPGfdbcxXuVgOKRepbkq4xv1pUpMQ4BUmlkejDRSP+5SIR3\niEthg+FU6GRSQbORE6nhrKjGBk8fpNpozQZVc2VySUTCwHIEEAEIACYFAlRJ\nbc8GCwkIBwMCCRA+tiWe3yHfJAQVCAIKAxYCAQIbAwIeAQAA9J0H/RLR/Uwt\nCakrPKtfeGaNuOI45SRTNxM8TklC6tM28sJSzkX8qKPzvI1PxyLhs/i0/fCQ\n7Z5bU6n41oLuqUt2S9vy+ABlChKAeziOqCHUcMzHOtbKiPkKW88aO687nx+A\nol2XOnMTkVIC+edMUgnKp6tKtZnbO4ea6Cg88TFuli4hLHNXTfCECswuxHOc\nAO1OKDRrCd08iPI5CLNCIV60QnduitE1vF6ehgrH25Vl6LEdd8vPVlTYAvsa\n6ySk2RIrHNLUZZ3iII3MBFL8HyINp/XA1BQP+QbH801uSLq8agxM4iFT9C+O\nD147SawUGhjD5RG7T+YtqItzgA1V9l277EXHwwYEVEltzwEIAJD57uX6bOc4\nTgf3utfL/4hdyoqIMVHkYQOvE27wPsZxX08QsdlaNeGji9Ap2ifIDuckUqn6\nJi9jtZDKtOzdTBm6rnG5nPmkn6BJXPhnecQRP8N0XBISnAGmE4t+bxtts5Wb\nqeMdxJYqMiGqzrLBRJEIDTcg3+QF2Y3RywOqlcXqgG/xX++PsvR1Jiz0rEVP\nTcBc7ytyb/Av7mx1S802HRYGJHOFtVLoPTrtPCvv+DRDK8JzxQW2XSQLlI0M\n9s1tmYhCogYIIqKx9qOTd5mFJ1hJlL6i9xDkvE21qPFASFtww5tiYmUfFaxI\nLwbXPZlQ1I/8fuaUdOxctQ+g40ZgHPcAEQEAAf4JAwgdUg8ubE2BT2DITBD+\nXFgjrnUlQBilbN8/do/36KHuImSPO/GGLzKh4+oXxrvLc5fQLjeO+bzeen4u\nCOCBRO0hG7KpJPhQ6+T02uEF6LegE1sEz5hp6BpKUdPZ1+8799Rylb5kubC5\nIKnLqqpGDbH3hIsmSV3CG/ESkaGMLc/K0ZPt1JRWtUQ9GesXT0v6fdM5GB/L\ncZWFdDoYgZAw5BtymE44knIodfDAYJ4DHnPCh/oilWe1qVTQcNMdtkpBgkuo\nTHecqEmiODQz5EX8pVmS596XsnPO299Lo3TbaHUQo7EC6Au1Au9+b5hC1pDa\nFVCLcproi/Cgch0B/NOCFkVLYmp6BEljRj2dSZRWbO0vgl9kFmJEeiiH41+k\nEAI6PASSKZs3BYLFc2I8mBkcvt90kg4MTBjreuk0uWf1hdH2Rv8zprH4h5Uh\ngjx5nUDX8WXyeLxTU5EBKry+A2DIe0Gm0/waxp6lBlUl+7ra28KYEoHm8Nq/\nN9FCuEhFkFgw6EwUp7jsrFcqBKvmni6jyplm+mJXi3CK+IiNcqub4XPnBI97\nlR19fupB/Y6M7yEaxIM8fTQXmP+x/fe8zRphdo+7o+pJQ3hk5LrrNPK8GEZ6\nDLDOHjZzROhOgBvWtbxRktHk+f5YpuQL+xWd33IV1xYSSHuoAm0Zwt0QJxBs\noFBwJEq1NWM4FxXJBogvzV7KFhl/hXgtvx+GaMv3y8gucj+gE89xVv0XBXjl\n5dy5/PgCI0Id+KAFHyKpJA0N0h8O4xdJoNyIBAwDZ8LHt0vlnLGwcJFR9X7/\nPfWe0PFtC3d7cYY3RopDhnRP7MZs1Wo9nZ4IvlXoEsE2nPkWcns+Wv5Yaewr\ns2ra9ZIK7IIJhqKKgmQtCeiXyFwTq+kfunDnxeCavuWL3HuLKIOZf7P9vXXt\nXgEir9rCwF8EGAEIABMFAlRJbdIJED62JZ7fId8kAhsMAAD+LAf+KT1EpkwH\n0ivTHmYako+6qG6DCtzd3TibWw51cmbY20Ph13NIS/MfBo828S9SXm/sVUzN\n/r7qZgZYfI0/j57tG3BguVGm53qya4bINKyi1RjK6aKo/rrzRkh5ZVD5rVNO\nE2zzvyYAnLUWG9AV1OYDxcgLrXqEMWlqZAo+Wmg7VrTBmdCGs/BPvscNgQRr\n6Gpjgmv9ru6LjRL7vFhEcov/tkBLj+CtaWWFTd1s2vBLOs4rCsD9TT/23vfw\nCnokvvVjKYN5oviy61yhpqF1rWlOsxZ4+2sKW3Pq7JLBtmzsZegTONfcQAf7\nqqGRQm3MxoTdgQUShAwbNwNNQR9cInfMnA==\r\n=2wIY\r\n-----END PGP PRIVATE KEY BLOCK-----\r\n", + "Fingerprint": "c93f767df53b0ca8395cfde90483475164ec6353", + "Activation": null, + "Primary": 1 + } + ] + } +} diff --git a/pkg/pmapi/testdata/symmetric_key.json b/pkg/pmapi/testdata/symmetric_key.json new file mode 100644 index 00000000..7c25796b --- /dev/null +++ b/pkg/pmapi/testdata/symmetric_key.json @@ -0,0 +1,4 @@ +{ + "Key": "ExXmnSiQ2QCey20YLH6qlLhkY3xnIBC1AwlIXwK/HvY=", + "Algo": "aes256" +} diff --git a/pkg/pmapi/testdata/testPrivateKey b/pkg/pmapi/testdata/testPrivateKey new file mode 100644 index 00000000..b8df89f9 --- /dev/null +++ b/pkg/pmapi/testdata/testPrivateKey @@ -0,0 +1,63 @@ +-----BEGIN PGP PRIVATE KEY BLOCK----- +Version: OpenPGP.js v0.7.1 +Comment: http://openpgpjs.org + +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 +qqGRQm3MxoTdgQUShAwbNwNNQR9cInfMnA== +=2wIY +-----END PGP PRIVATE KEY BLOCK----- diff --git a/pkg/pmapi/testdata/testPrivateKeyLegacy b/pkg/pmapi/testdata/testPrivateKeyLegacy new file mode 100644 index 00000000..e058468d --- /dev/null +++ b/pkg/pmapi/testdata/testPrivateKeyLegacy @@ -0,0 +1,63 @@ +-----BEGIN PGP PRIVATE KEY BLOCK----- +Version: OpenPGP.js v0.9.0 +Comment: http://openpgpjs.org + +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 +PPekG4ttm6z3/BqqVplanIRSXlsqyp6J8A== +=Pyb1 +-----END PGP PRIVATE KEY BLOCK----- diff --git a/pkg/pmapi/testdata/testPublicKey b/pkg/pmapi/testdata/testPublicKey new file mode 100644 index 00000000..cd70b249 --- /dev/null +++ b/pkg/pmapi/testdata/testPublicKey @@ -0,0 +1,33 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: OpenPGP.js v0.7.1 +Comment: http://openpgpjs.org + +xsBNBFRJbc0BCAC0mMLZPDBbtSCWvxwmOfXfJkE2+ssM3ux21LhD/bPiWefE +WSHlCjJ8PqPHy7snSiUuxuj3f9AvXPvg+mjGLBwu1/QsnSP24sl3qD2onl39 +vPiLJXUqZs20ZRgnvX70gjkgEzMFBxINiy2MTIG+4RU8QA7y8KzWev0btqKi +MeVa+GLEHhgZ2KPOn4Jv1q4bI9hV0C9NUe2tTXS6/Vv3vbCY7lRR0kbJ65T5 +c8CmpqJuASIJNrSXM/Q3NnnsY4kBYH0s5d2FgbASQvzrjuC2rngUg0EoPsrb +DEVRA2/BCJonw7aASiNCrSP92lkZdtYlax/pcoE/mQ4WSwySFmcFT7yFABEB +AAHNBlVzZXJJRMLAcgQQAQgAJgUCVEltzwYLCQgHAwIJED62JZ7fId8kBBUI +AgoDFgIBAhsDAh4BAAD0nQf9EtH9TC0JqSs8q194Zo244jjlJFM3EzxOSULq +0zbywlLORfyoo/O8jU/HIuGz+LT98JDtnltTqfjWgu6pS3ZL2/L4AGUKEoB7 +OI6oIdRwzMc61sqI+Qpbzxo7rzufH4CiXZc6cxORUgL550xSCcqnq0q1mds7 +h5roKDzxMW6WLiEsc1dN8IQKzC7Ec5wA7U4oNGsJ3TyI8jkIs0IhXrRCd26K +0TW8Xp6GCsfblWXosR13y89WVNgC+xrrJKTZEisc0tRlneIgjcwEUvwfIg2n +9cDUFA/5BsfzTW5IurxqDEziIVP0L44PXjtJrBQaGMPlEbtP5i2oi3OADVX2 +XbvsRc7ATQRUSW3PAQgAkPnu5fps5zhOB/e618v/iF3KiogxUeRhA68TbvA+ +xnFfTxCx2Vo14aOL0CnaJ8gO5yRSqfomL2O1kMq07N1MGbqucbmc+aSfoElc ++Gd5xBE/w3RcEhKcAaYTi35vG22zlZup4x3ElioyIarOssFEkQgNNyDf5AXZ +jdHLA6qVxeqAb/Ff74+y9HUmLPSsRU9NwFzvK3Jv8C/ubHVLzTYdFgYkc4W1 +Uug9Ou08K+/4NEMrwnPFBbZdJAuUjQz2zW2ZiEKiBggiorH2o5N3mYUnWEmU +vqL3EOS8TbWo8UBIW3DDm2JiZR8VrEgvBtc9mVDUj/x+5pR07Fy1D6DjRmAc +9wARAQABwsBfBBgBCAATBQJUSW3SCRA+tiWe3yHfJAIbDAAA/iwH/ik9RKZM +B9Ir0x5mGpKPuqhugwrc3d04m1sOdXJm2NtD4ddzSEvzHwaPNvEvUl5v7FVM +zf6+6mYGWHyNP4+e7RtwYLlRpud6smuGyDSsotUYyumiqP6680ZIeWVQ+a1T +ThNs878mAJy1FhvQFdTmA8XIC616hDFpamQKPlpoO1a0wZnQhrPwT77HDYEE +a+hqY4Jr/a7ui40S+7xYRHKL/7ZAS4/grWllhU3dbNrwSzrOKwrA/U0/9t73 +8Ap6JL71YymDeaL4sutcoaahda1pTrMWePtrCltz6uySwbZs7GXoEzjX3EAH ++6qhkUJtzMaE3YEFEoQMGzcDTUEfXCJ3zJw= +=yT9U +-----END PGP PUBLIC KEY BLOCK----- + diff --git a/pkg/pmapi/users.go b/pkg/pmapi/users.go new file mode 100644 index 00000000..1815f1db --- /dev/null +++ b/pkg/pmapi/users.go @@ -0,0 +1,130 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + pmcrypto "github.com/ProtonMail/gopenpgp/crypto" + "github.com/getsentry/raven-go" +) + +// Role values. +const ( + FreeUserRole = iota + PaidMemberRole + PaidAdminRole +) + +// User status +const ( + DeletedUser = 0 + DisabledUser = 1 + ActiveUser = 2 + VPNAdminUser = 3 + AdminUser = 4 + SuperUser = 5 +) + +// Delinquent values. +const ( + CurrentUser = iota + AvailableUser + OverdueUser + DelinquentUser + NoReceiveUser +) + +// PMSignature values. +const ( + PMSignatureDisabled = iota + PMSignatureEnabled + PMSignatureLocked +) + +// User holds the user details. +type User struct { + ID string + Name string + UsedSpace int64 + Currency string + Credit int + MaxSpace int64 + MaxUpload int64 + Role int + Private int + Subscribed int + Services int + VPN struct { + Status int + ExpirationTime int + PlanName string + MaxConnect int + MaxTier int + } + Deliquent int + Keys PMKeys +} + +// UserRes holds structure of JSON response. +type UserRes struct { + Res + + User *User +} + +// KeyRing returns the (possibly unlocked) PMKeys KeyRing. +func (u *User) KeyRing() *pmcrypto.KeyRing { + return u.Keys.KeyRing +} + +// UpdateUser retrieves details about user and loads its addresses. +func (c *Client) UpdateUser() (user *User, err error) { + req, err := NewRequest("GET", "/users", nil) + if err != nil { + return + } + + var res UserRes + if err = c.DoJSON(req, &res); err != nil { + return + } + + user, err = res.User, res.Err() + if err != nil { + return nil, err + } + + c.user = user + c.log.Infoln("update user:", user.ID) + raven.SetUserContext(&raven.User{ID: user.ID}) + + var tmpList AddressList + if tmpList, err = c.GetAddresses(); err == nil { + c.addresses = tmpList + } + + return user, err +} + +// CurrentUser return currently active user or user will be updated. +func (c *Client) CurrentUser() (user *User, err error) { + if c.user != nil && len(c.addresses) != 0 { + user = c.user + return + } + return c.UpdateUser() +} diff --git a/pkg/pmapi/users_test.go b/pkg/pmapi/users_test.go new file mode 100644 index 00000000..9671f3ac --- /dev/null +++ b/pkg/pmapi/users_test.go @@ -0,0 +1,97 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package pmapi + +import ( + "fmt" + "net/http" + "net/url" + "testing" + + pmcrypto "github.com/ProtonMail/gopenpgp/crypto" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + r "github.com/stretchr/testify/require" +) + +var testCurrentUser = &User{ + ID: "MJLke8kWh1BBvG95JBIrZvzpgsZ94hNNgjNHVyhXMiv4g9cn6SgvqiIFR5cigpml2LD_iUk_3DkV29oojTt3eA==", + Name: "jason", + UsedSpace: 96691332, + Currency: "USD", + Role: 2, + Subscribed: 1, + Services: 1, + MaxSpace: 10737418240, + MaxUpload: 26214400, + Private: 1, + Keys: *loadPMKeys(readTestFile("keyring_userKey_JSON", false)), +} + +func routeGetUsers(tb testing.TB, w http.ResponseWriter, r *http.Request) string { + Ok(tb, checkMethodAndPath(r, "GET", "/users")) + Ok(tb, isAuthReq(r, testUID, testAccessToken)) + + return "users/get_response.json" +} + +const testPublicKeysBody = `{ + "Code": 1000, + "RecipientType": 1, + "MIMEType": "text/html", + "Keys": [ + { "Flags": 3, "PublicKey": "-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: OpenPGP.js v0.7.1\nComment: http://openpgpjs.org\n\nxsBNBFSI0BMBB/9td6B5RDzVSFTlFzYOS4JxIb5agtNW1rbA4FeLoC47bGLR\n8E42IA6aKcO4H0vOZ1lFms0URiKk1DjCMXn3AUErbxqiV5IATRZLwliH6vwy\nPI6j5rtGF8dyxYfwmLtoUNkDcPdcFEb4NCdowsN7e8tKU0bcpouZcQhAqawC\n9nEdaG/gS5w+2k4hZX2lOKS1EF5SvP48UadlspEK2PLAIp5wB9XsFS9ey2wu\nelzkSfDh7KUAlteqFGSMqIgYH62/gaKm+TcckfZeyiMHWFw6sfrcFQ3QOZPq\nahWt0Rn9XM5xBAxx5vW0oceuQ1vpvdfFlM5ix4gn/9w6MhmStaCee8/fABEB\nAAHNBlVzZXJJRMLAcgQQAQgAJgUCVIjQHQYLCQgHAwIJEASDR1Fk7GNTBBUI\nAgoDFgIBAhsDAh4BAADmhAf/Yt0mCfWqQ25NNGUN14pKKgnPm68zwj1SmMGa\npU7+7ItRpoFNaDwV5QYiQSLC1SvSb1ZeKoY928GPKfqYyJlBpTPL9zC1OHQj\n9+2yYauHjYW9JWQM7hst2S2LBcdiQPOs3ybWPaO9yaccV4thxKOCPvyClaS5\nb9T4Iv9GEVZQIUvArkwI8hyzIi6skRgxflGheq1O+S1W4Gzt2VtYvo8g8r6W\nGzAGMw2nrs2h0+vUr+dLDgIbFCTc5QU99d5jE/e5Hw8iqBxv9tqB1hVATf8T\nwC8aU5MTtxtabOiBgG0PsBs6oIwjFqEjpOIza2/AflPZfo7stp6IiwbwvTHo\n1NlHoM7ATQRUiNAdAQf/eOLJYxX4lUQUzrNQgASDNE8gJPj7ywcGzySyqr0Y\n5rbG57EjtKMIgZrpzJRpSCuRbBjfsltqJ5Q9TBAbPO+oR3rue0LqPKMnmr/q\nKsHswBJRfsb/dbktUNmv/f7R9IVyOuvyP6RgdGeloxdGNeWiZSA6AZYI+WGc\nxaOvVDPz8thtnML4G4MUhXxxNZ7JzQ0Lfz6mN8CCkblIP5xpcJsyRU7lUsGD\nEJGZX0JH/I8bRVN1Xu08uFinIkZyiXRJ5ZGgF3Dns6VbIWmbttY54tBELtk+\n5g9pNSl9qiYwiCdwuZrA//NmD3xlZIN8sG4eM7ZUibZ23vEq+bUt1++6Mpba\nGQARAQABwsBfBBgBCAATBQJUiNAfCRAEg0dRZOxjUwIbDAAAlpMH/085qZdO\nmGRAlbvViUNhF2rtHvCletC48WHGO1ueSh9VTxalkP21YAYLJ4JgJzArJ7tH\nlEeiKiHm8YU9KhLe11Yv/o3AiKIAQjJiQluvk+mWdMcddB4fBjL6ttMTRAXe\ngHnjtMoamHbSZdeUTUadv05Fl6ivWtpXlODG4V02YvDiGBUbDosdGXEqDtpT\ng6MYlj3QMvUiUNQvt7YGMJS8A9iQ9qBNzErgRW8L6CON2RmpQ/wgwP5nwUHz\nJjY51d82Vj8bZeI8LdsX41SPoUhyC7kmNYpw9ZRy7NlrCt8dBIOB4/BKEJ2G\nClW54lp9eeOfYTsdTSbn9VaSO0E6m2/Q4Tk=\n=WFtr\n-----END PGP PUBLIC KEY BLOCK-----"}, + { "Flags": 1, "PublicKey": "-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: OpenPGP.js v0.7.1\nComment: http://openpgpjs.org\n\nxsBNBFSI0BMBB/9td6B5RDzVSFTlFzYOS4JxIb5agtNW1rbA4FeLoC47bGLR\n8E42IA6aKcO4H0vOZ1lFms0URiKk1DjCMXn3AUErbxqiV5IATRZLwliH6vwy\nPI6j5rtGF8dyxYfwmLtoUNkDcPdcFEb4NCdowsN7e8tKU0bcpouZcQhAqawC\n9nEdaG/gS5w+2k4hZX2lOKS1EF5SvP48UadlspEK2PLAIp5wB9XsFS9ey2wu\nelzkSfDh7KUAlteqFGSMqIgYH62/gaKm+TcckfZeyiMHWFw6sfrcFQ3QOZPq\nahWt0Rn9XM5xBAxx5vW0oceuQ1vpvdfFlM5ix4gn/9w6MhmStaCee8/fABEB\nAAHNBlVzZXJJRMLAcgQQAQgAJgUCVIjQHQYLCQgHAwIJEASDR1Fk7GNTBBUI\nAgoDFgIBAhsDAh4BAADmhAf/Yt0mCfWqQ25NNGUN14pKKgnPm68zwj1SmMGa\npU7+7ItRpoFNaDwV5QYiQSLC1SvSb1ZeKoY928GPKfqYyJlBpTPL9zC1OHQj\n9+2yYauHjYW9JWQM7hst2S2LBcdiQPOs3ybWPaO9yaccV4thxKOCPvyClaS5\nb9T4Iv9GEVZQIUvArkwI8hyzIi6skRgxflGheq1O+S1W4Gzt2VtYvo8g8r6W\nGzAGMw2nrs2h0+vUr+dLDgIbFCTc5QU99d5jE/e5Hw8iqBxv9tqB1hVATf8T\nwC8aU5MTtxtabOiBgG0PsBs6oIwjFqEjpOIza2/AflPZfo7stp6IiwbwvTHo\n1NlHoM7ATQRUiNAdAQf/eOLJYxX4lUQUzrNQgASDNE8gJPj7ywcGzySyqr0Y\n5rbG57EjtKMIgZrpzJRpSCuRbBjfsltqJ5Q9TBAbPO+oR3rue0LqPKMnmr/q\nKsHswBJRfsb/dbktUNmv/f7R9IVyOuvyP6RgdGeloxdGNeWiZSA6AZYI+WGc\nxaOvVDPz8thtnML4G4MUhXxxNZ7JzQ0Lfz6mN8CCkblIP5xpcJsyRU7lUsGD\nEJGZX0JH/I8bRVN1Xu08uFinIkZyiXRJ5ZGgF3Dns6VbIWmbttY54tBELtk+\n5g9pNSl9qiYwiCdwuZrA//NmD3xlZIN8sG4eM7ZUibZ23vEq+bUt1++6Mpba\nGQARAQABwsBfBBgBCAATBQJUiNAfCRAEg0dRZOxjUwIbDAAAlpMH/085qZdO\nmGRAlbvViUNhF2rtHvCletC48WHGO1ueSh9VTxalkP21YAYLJ4JgJzArJ7tH\nlEeiKiHm8YU9KhLe11Yv/o3AiKIAQjJiQluvk+mWdMcddB4fBjL6ttMTRAXe\ngHnjtMoamHbSZdeUTUadv05Fl6ivWtpXlODG4V02YvDiGBUbDosdGXEqDtpT\ng6MYlj3QMvUiUNQvt7YGMJS8A9iQ9qBNzErgRW8L6CON2RmpQ/wgwP5nwUHz\nJjY51d82Vj8bZeI8LdsX41SPoUhyC7kmNYpw9ZRy7NlrCt8dBIOB4/BKEJ2G\nClW54lp9eeOfYTsdTSbn9VaSO0E6m2/Q4Tk=\n=WFtr\n-----END PGP PUBLIC KEY BLOCK-----"} + ]}` + +func TestClient_CurrentUser(t *testing.T) { + finish, c := newTestServerCallbacks(t, + routeGetUsers, + routeGetAddresses, + ) + defer finish() + c.uid = testUID + c.accessToken = testAccessToken + + user, err := c.CurrentUser() + r.Nil(t, err) + + // Ignore KeyRings during the check because they have unexported fields and cannot be compared + r.True(t, cmp.Equal(user, testCurrentUser, cmpopts.IgnoreTypes(&pmcrypto.KeyRing{}))) + + r.Nil(t, c.UnlockAddresses([]byte(testMailboxPassword))) +} + +func TestClient_PublicKeys(t *testing.T) { + email := "jason@protonmail.com" + escaped := url.QueryEscape(email) + s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Ok(t, checkMethodAndPath(r, "GET", "/keys?Email="+escaped)) + fmt.Fprint(w, testPublicKeysBody) + })) + defer s.Close() + + keys, err := c.PublicKeys([]string{email}) + if err != nil { + t.Fatal("Expected no error while getting current user, got:", err) + } + + if len(keys) != 1 || keys[escaped] == nil { + t.Fatalf("Expected only one key for %v, got %#v", email, keys) + } +} diff --git a/pkg/ports/ports.go b/pkg/ports/ports.go new file mode 100644 index 00000000..ce776cd6 --- /dev/null +++ b/pkg/ports/ports.go @@ -0,0 +1,65 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package ports + +import ( + "net" + "strconv" +) + +const ( + maxPortNumber = 65535 +) + +// IsPortFree checks if the port is free to use. +func IsPortFree(port int) bool { + if !(0 < port && port < maxPortNumber) { + return false + } + stringPort := ":" + strconv.Itoa(port) + isFree := !isOccupied(stringPort) + return isFree +} + +func isOccupied(port string) bool { + // Try to create server at port. + dummyserver, err := net.Listen("tcp", port) + if err != nil { + return true + } + _ = dummyserver.Close() + return false +} + +// FindFreePortFrom finds first empty port, starting with `startPort`. +func FindFreePortFrom(startPort int) int { + loopedOnce := false + freePort := startPort + for !IsPortFree(freePort) { + freePort++ + if freePort >= maxPortNumber { + freePort = 1 + if loopedOnce { + freePort = startPort + break + } + loopedOnce = true + } + } + return freePort +} diff --git a/pkg/ports/ports_test.go b/pkg/ports/ports_test.go new file mode 100644 index 00000000..e1c4a1c8 --- /dev/null +++ b/pkg/ports/ports_test.go @@ -0,0 +1,56 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package ports + +import ( + "net" + "strconv" + "testing" + + "github.com/stretchr/testify/require" +) + +const testPort = 18080 + +func TestFreePort(t *testing.T) { + require.True(t, IsPortFree(testPort), "port should be empty") +} + +func TestOccupiedPort(t *testing.T) { + dummyserver, err := net.Listen("tcp", ":"+strconv.Itoa(testPort)) + require.NoError(t, err) + + require.True(t, !IsPortFree(testPort), "port should be occupied") + + _ = dummyserver.Close() +} + +func TestFindFreePortFromDirectly(t *testing.T) { + foundPort := FindFreePortFrom(testPort) + require.Equal(t, testPort, foundPort) +} + +func TestFindFreePortFromNextOne(t *testing.T) { + dummyserver, err := net.Listen("tcp", ":"+strconv.Itoa(testPort)) + require.NoError(t, err) + + foundPort := FindFreePortFrom(testPort) + require.Equal(t, testPort+1, foundPort) + + _ = dummyserver.Close() +} diff --git a/pkg/srp/hash.go b/pkg/srp/hash.go new file mode 100644 index 00000000..dfb50771 --- /dev/null +++ b/pkg/srp/hash.go @@ -0,0 +1,107 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package srp + +import ( + "bytes" + "crypto/md5" //nolint[gosec] + "crypto/sha512" + "encoding/base64" + "encoding/hex" + "errors" + "strings" + + "github.com/jameskeane/bcrypt" +) + +// BCryptHash function bcrypt algorithm to hash password with salt +func BCryptHash(password string, salt string) (string, error) { + return bcrypt.Hash(password, salt) +} + +// ExpandHash extends the byte data for SRP flow +func ExpandHash(data []byte) []byte { + part0 := sha512.Sum512(append(data, 0)) + part1 := sha512.Sum512(append(data, 1)) + part2 := sha512.Sum512(append(data, 2)) + part3 := sha512.Sum512(append(data, 3)) + return bytes.Join([][]byte{ + part0[:], + part1[:], + part2[:], + part3[:], + }, []byte{}) +} + +// HashPassword returns the hash of password argument. Based on version number +// following arguments are used in addition to password: +// * 0, 1, 2: userName and modulus +// * 3, 4: salt and modulus +func HashPassword(authVersion int, password, userName string, salt, modulus []byte) ([]byte, error) { + switch authVersion { + case 4, 3: + return hashPasswordVersion3(password, salt, modulus) + case 2: + return hashPasswordVersion2(password, userName, modulus) + case 1: + return hashPasswordVersion1(password, userName, modulus) + case 0: + return hashPasswordVersion0(password, userName, modulus) + default: + return nil, errors.New("pmapi: unsupported auth version") + } +} + +// CleanUserName returns the input string in lower-case without characters `_`, +// `.` and `-`. +func CleanUserName(userName string) string { + userName = strings.Replace(userName, "-", "", -1) + userName = strings.Replace(userName, ".", "", -1) + userName = strings.Replace(userName, "_", "", -1) + return strings.ToLower(userName) +} + +func hashPasswordVersion3(password string, salt, modulus []byte) (res []byte, err error) { + encodedSalt := base64.NewEncoding("./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789").WithPadding(base64.NoPadding).EncodeToString(append(salt, []byte("proton")...)) + crypted, err := BCryptHash(password, "$2y$10$"+encodedSalt) + if err != nil { + return + } + + return ExpandHash(append([]byte(crypted), modulus...)), nil +} + +func hashPasswordVersion2(password, userName string, modulus []byte) (res []byte, err error) { + return hashPasswordVersion1(password, CleanUserName(userName), modulus) +} + +func hashPasswordVersion1(password, userName string, modulus []byte) (res []byte, err error) { + prehashed := md5.Sum([]byte(strings.ToLower(userName))) //nolint[gosec] + encodedSalt := hex.EncodeToString(prehashed[:]) + crypted, err := BCryptHash(password, "$2y$10$"+encodedSalt) + if err != nil { + return + } + + return ExpandHash(append([]byte(crypted), modulus...)), nil +} + +func hashPasswordVersion0(password, userName string, modulus []byte) (res []byte, err error) { + prehashed := sha512.Sum512([]byte(password)) + return hashPasswordVersion1(base64.StdEncoding.EncodeToString(prehashed[:]), userName, modulus) +} diff --git a/pkg/srp/srp.go b/pkg/srp/srp.go new file mode 100644 index 00000000..252244cb --- /dev/null +++ b/pkg/srp/srp.go @@ -0,0 +1,219 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package srp + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "errors" + "math/big" + + "golang.org/x/crypto/openpgp" + "golang.org/x/crypto/openpgp/clearsign" +) + +//nolint[gochecknoglobals] +var ( + ErrDataAfterModulus = errors.New("pm-srp: extra data after modulus") + ErrInvalidSignature = errors.New("pm-srp: invalid modulus signature") + RandReader = rand.Reader +) + +// Store random reader in a variable to be able to overwrite it in tests + +// Amored pubkey for modulus verification +const modulusPubkey = `-----BEGIN PGP PUBLIC KEY BLOCK----- + +xjMEXAHLgxYJKwYBBAHaRw8BAQdAFurWXXwjTemqjD7CXjXVyKf0of7n9Ctm +L8v9enkzggHNEnByb3RvbkBzcnAubW9kdWx1c8J3BBAWCgApBQJcAcuDBgsJ +BwgDAgkQNQWFxOlRjyYEFQgKAgMWAgECGQECGwMCHgEAAPGRAP9sauJsW12U +MnTQUZpsbJb53d0Wv55mZIIiJL2XulpWPQD/V6NglBd96lZKBmInSXX/kXat +Sv+y0io+LR8i2+jV+AbOOARcAcuDEgorBgEEAZdVAQUBAQdAeJHUz1c9+KfE +kSIgcBRE3WuXC4oj5a2/U3oASExGDW4DAQgHwmEEGBYIABMFAlwBy4MJEDUF +hcTpUY8mAhsMAAD/XQD8DxNI6E78meodQI+wLsrKLeHn32iLvUqJbVDhfWSU +WO4BAMcm1u02t4VKw++ttECPt+HUgPUq5pqQWe5Q2cW4TMsE +=Y4Mw +-----END PGP PUBLIC KEY BLOCK-----` + +// ReadClearSignedMessage reads the clear text from signed message and verifies +// signature. There must be no data appended after signed message in input string. +// The message must be sign by key corresponding to `modulusPubkey`. +func ReadClearSignedMessage(signedMessage string) (string, error) { + modulusBlock, rest := clearsign.Decode([]byte(signedMessage)) + if len(rest) != 0 { + return "", ErrDataAfterModulus + } + + modulusKeyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(modulusPubkey))) + if err != nil { + return "", errors.New("pm-srp: can not read modulus pubkey") + } + + _, err = openpgp.CheckDetachedSignature(modulusKeyring, bytes.NewReader(modulusBlock.Bytes), modulusBlock.ArmoredSignature.Body, nil) + if err != nil { + return "", ErrInvalidSignature + } + + return string(modulusBlock.Bytes), nil +} + +// SrpProofs object +type SrpProofs struct { //nolint[golint] + ClientProof, ClientEphemeral, ExpectedServerProof []byte +} + +// SrpAuth stores byte data for the calculation of SRP proofs +type SrpAuth struct { //nolint[golint] + Modulus, ServerEphemeral, HashedPassword []byte +} + +// NewSrpAuth creates new SrpAuth from strings input. Salt and server ephemeral are in +// base64 format. Modulus is base64 with signature attached. The signature is +// verified against server key. The version controls password hash algorithm. +func NewSrpAuth(version int, username, password, salt, signedModulus, serverEphemeral string) (auth *SrpAuth, err error) { + data := &SrpAuth{} + + // Modulus + var modulus string + modulus, err = ReadClearSignedMessage(signedModulus) + if err != nil { + return + } + data.Modulus, err = base64.StdEncoding.DecodeString(modulus) + if err != nil { + return + } + + // Password + var decodedSalt []byte + if version >= 3 { + decodedSalt, err = base64.StdEncoding.DecodeString(salt) + if err != nil { + return + } + } + data.HashedPassword, err = HashPassword(version, password, username, decodedSalt, data.Modulus) + if err != nil { + return + } + + // Server ephermeral + data.ServerEphemeral, err = base64.StdEncoding.DecodeString(serverEphemeral) + if err != nil { + return + } + + return data, nil +} + +// GenerateSrpProofs calculates SPR proofs. +func (s *SrpAuth) GenerateSrpProofs(length int) (res *SrpProofs, err error) { //nolint[funlen] + toInt := func(arr []byte) *big.Int { + var reversed = make([]byte, len(arr)) + for i := 0; i < len(arr); i++ { + reversed[len(arr)-i-1] = arr[i] + } + return big.NewInt(0).SetBytes(reversed) + } + + fromInt := func(num *big.Int) []byte { + var arr = num.Bytes() + var reversed = make([]byte, length/8) + for i := 0; i < len(arr); i++ { + reversed[len(arr)-i-1] = arr[i] + } + return reversed + } + + generator := big.NewInt(2) + multiplier := toInt(ExpandHash(append(fromInt(generator), s.Modulus...))) + + modulus := toInt(s.Modulus) + serverEphemeral := toInt(s.ServerEphemeral) + hashedPassword := toInt(s.HashedPassword) + + modulusMinusOne := big.NewInt(0).Sub(modulus, big.NewInt(1)) + + if modulus.BitLen() != length { + return nil, errors.New("pm-srp: SRP modulus has incorrect size") + } + + multiplier = multiplier.Mod(multiplier, modulus) + + if multiplier.Cmp(big.NewInt(1)) <= 0 || multiplier.Cmp(modulusMinusOne) >= 0 { + return nil, errors.New("pm-srp: SRP multiplier is out of bounds") + } + + if generator.Cmp(big.NewInt(1)) <= 0 || generator.Cmp(modulusMinusOne) >= 0 { + return nil, errors.New("pm-srp: SRP generator is out of bounds") + } + + if serverEphemeral.Cmp(big.NewInt(1)) <= 0 || serverEphemeral.Cmp(modulusMinusOne) >= 0 { + return nil, errors.New("pm-srp: SRP server ephemeral is out of bounds") + } + + // Check primality + // Doing exponentiation here is faster than a full call to ProbablyPrime while + // still perfectly accurate by Pocklington's theorem + if big.NewInt(0).Exp(big.NewInt(2), modulusMinusOne, modulus).Cmp(big.NewInt(1)) != 0 { + return nil, errors.New("pm-srp: SRP modulus is not prime") + } + + // Check safe primality + if !big.NewInt(0).Rsh(modulus, 1).ProbablyPrime(10) { + return nil, errors.New("pm-srp: SRP modulus is not a safe prime") + } + + var clientSecret, clientEphemeral, scramblingParam *big.Int + for { + for { + clientSecret, err = rand.Int(RandReader, modulusMinusOne) + if err != nil { + return + } + + if clientSecret.Cmp(big.NewInt(int64(length*2))) > 0 { // Very likely + break + } + } + + clientEphemeral = big.NewInt(0).Exp(generator, clientSecret, modulus) + scramblingParam = toInt(ExpandHash(append(fromInt(clientEphemeral), fromInt(serverEphemeral)...))) + if scramblingParam.Cmp(big.NewInt(0)) != 0 { // Very likely + break + } + } + + subtracted := big.NewInt(0).Sub(serverEphemeral, big.NewInt(0).Mod(big.NewInt(0).Mul(big.NewInt(0).Exp(generator, hashedPassword, modulus), multiplier), modulus)) + if subtracted.Cmp(big.NewInt(0)) < 0 { + subtracted.Add(subtracted, modulus) + } + exponent := big.NewInt(0).Mod(big.NewInt(0).Add(big.NewInt(0).Mul(scramblingParam, hashedPassword), clientSecret), modulusMinusOne) + sharedSession := big.NewInt(0).Exp(subtracted, exponent, modulus) + + clientProof := ExpandHash(bytes.Join([][]byte{fromInt(clientEphemeral), fromInt(serverEphemeral), fromInt(sharedSession)}, []byte{})) + serverProof := ExpandHash(bytes.Join([][]byte{fromInt(clientEphemeral), clientProof, fromInt(sharedSession)}, []byte{})) + + return &SrpProofs{ClientEphemeral: fromInt(clientEphemeral), ClientProof: clientProof, ExpectedServerProof: serverProof}, nil +} + +// GenerateVerifier verifier for update pwds and create accounts +func (s *SrpAuth) GenerateVerifier(length int) ([]byte, error) { + return nil, errors.New("pm-srp: the client doesn't need SRP GenerateVerifier") +} diff --git a/pkg/srp/srp_test.go b/pkg/srp/srp_test.go new file mode 100644 index 00000000..3b884ec8 --- /dev/null +++ b/pkg/srp/srp_test.go @@ -0,0 +1,111 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package srp + +import ( + "bytes" + "encoding/base64" + "math/rand" + "testing" +) + +const ( + testServerEphemeral = "l13IQSVFBEV0ZZREuRQ4ZgP6OpGiIfIjbSDYQG3Yp39FkT2B/k3n1ZhwqrAdy+qvPPFq/le0b7UDtayoX4aOTJihoRvifas8Hr3icd9nAHqd0TUBbkZkT6Iy6UpzmirCXQtEhvGQIdOLuwvy+vZWh24G2ahBM75dAqwkP961EJMh67/I5PA5hJdQZjdPT5luCyVa7BS1d9ZdmuR0/VCjUOdJbYjgtIH7BQoZs+KacjhUN8gybu+fsycvTK3eC+9mCN2Y6GdsuCMuR3pFB0RF9eKae7cA6RbJfF1bjm0nNfWLXzgKguKBOeF3GEAsnCgK68q82/pq9etiUDizUlUBcA==" + testServerProof = "ffYFIhnhZJAflFJr9FfXbtdsBLkDGH+TUR5sj98wg0iVHyIhIVT6BeZD8tZA75tYlz7uYIanswweB3bjrGfITXfxERgQysQSoPUB284cX4VQm1IfTB/9LPma618MH8OULNluXVu2eizPWnvIn9VLXCaIX+38Xd6xOjmCQgfkpJy3Sh3ndikjqNCGWiKyvERVJi0nTmpAbHmcdeEp1K++ZRbebRhm2d018o/u4H2gu+MF39Hx12zMzEGNMwkNkgKSEQYlqmj57S6tW9JuB30zVZFnw6Krftg1QfJR6zCT1/J57OGp0A/7X/lC6Xz/I33eJvXOpG9GCRCbNiozFg9IXQ==" + + testClientProof = "8dQtp6zIeEmu3D93CxPdEiCWiAE86uDmK33EpxyqReMwUrm/bTL+zCkWa/X7QgLNrt2FBAriyROhz5TEONgZq/PqZnBEBym6Rvo708KHu6S4LFdZkVc0+lgi7yQpNhU8bqB0BCqdSWd3Fjd3xbOYgO7/vnFK+p9XQZKwEh2RmGv97XHwoxefoyXK6BB+VVMkELd4vL7vdqBiOBU3ufOlSp+0XBMVltQ4oi5l1y21pzOA9cw5WTPIPMcQHffNFq/rReHYnqbBqiLlSLyw6K0PcVuN3bvr3rVYfdS1CsM/Rv1DzXlBUl39B2j82y6hdyGcTeplGyAnAcu0CimvynKBvQ==" + testModulus = "W2z5HBi8RvsfYzZTS7qBaUxxPhsfHJFZpu3Kd6s1JafNrCCH9rfvPLrfuqocxWPgWDH2R8neK7PkNvjxto9TStuY5z7jAzWRvFWN9cQhAKkdWgy0JY6ywVn22+HFpF4cYesHrqFIKUPDMSSIlWjBVmEJZ/MusD44ZT29xcPrOqeZvwtCffKtGAIjLYPZIEbZKnDM1Dm3q2K/xS5h+xdhjnndhsrkwm9U9oyA2wxzSXFL+pdfj2fOdRwuR5nW0J2NFrq3kJjkRmpO/Genq1UW+TEknIWAb6VzJJJA244K/H8cnSx2+nSNZO3bbo6Ys228ruV9A8m6DhxmS+bihN3ttQ==" + testModulusClearSign = `-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA256 + +W2z5HBi8RvsfYzZTS7qBaUxxPhsfHJFZpu3Kd6s1JafNrCCH9rfvPLrfuqocxWPgWDH2R8neK7PkNvjxto9TStuY5z7jAzWRvFWN9cQhAKkdWgy0JY6ywVn22+HFpF4cYesHrqFIKUPDMSSIlWjBVmEJZ/MusD44ZT29xcPrOqeZvwtCffKtGAIjLYPZIEbZKnDM1Dm3q2K/xS5h+xdhjnndhsrkwm9U9oyA2wxzSXFL+pdfj2fOdRwuR5nW0J2NFrq3kJjkRmpO/Genq1UW+TEknIWAb6VzJJJA244K/H8cnSx2+nSNZO3bbo6Ys228ruV9A8m6DhxmS+bihN3ttQ== +-----BEGIN PGP SIGNATURE----- +Version: ProtonMail +Comment: https://protonmail.com + +wl4EARYIABAFAlwB1j0JEDUFhcTpUY8mAAD8CgEAnsFnF4cF0uSHKkXa1GIa +GO86yMV4zDZEZcDSJo0fgr8A/AlupGN9EdHlsrZLmTA1vhIx+rOgxdEff28N +kvNM7qIK +=q6vu +-----END PGP SIGNATURE-----` +) + +func init() { + // Only for tests, replace the default random reader by something that always + // return the same thing + RandReader = rand.New(rand.NewSource(42)) +} + +func TestReadClearSigned(t *testing.T) { + cleartext, err := ReadClearSignedMessage(testModulusClearSign) + if err != nil { + t.Fatal("Expected no error but have ", err) + } + if cleartext != testModulus { + t.Fatalf("Expected message\n\t'%s'\nbut have\n\t'%s'", testModulus, cleartext) + } + + lastChar := len(testModulusClearSign) + wrongSignature := testModulusClearSign[:lastChar-100] + wrongSignature += "c" + wrongSignature += testModulusClearSign[lastChar-99:] + _, err = ReadClearSignedMessage(wrongSignature) + if err != ErrInvalidSignature { + t.Fatal("Expected the ErrInvalidSignature but have ", err) + } + + wrongSignature = testModulusClearSign + "data after modulus" + _, err = ReadClearSignedMessage(wrongSignature) + if err != ErrDataAfterModulus { + t.Fatal("Expected the ErrDataAfterModulus but have ", err) + } +} + +func TestSRPauth(t *testing.T) { + srp, err := NewSrpAuth(4, "bridgetest", "test", "yKlc5/CvObfoiw==", testModulusClearSign, testServerEphemeral) + if err != nil { + t.Fatal("Expected no error but have ", err) + } + + proofs, err := srp.GenerateSrpProofs(2048) + if err != nil { + t.Fatal("Expected no error but have ", err) + } + + expectedProof, err := base64.StdEncoding.DecodeString(testServerProof) + if err != nil { + t.Fatal("Expected no error but have ", err) + } + if !bytes.Equal(proofs.ExpectedServerProof, expectedProof) { + t.Fatalf("Expected server proof\n\t'%s'\nbut have\n\t'%s'", + testServerProof, + base64.StdEncoding.EncodeToString(proofs.ExpectedServerProof), + ) + } + + expectedProof, err = base64.StdEncoding.DecodeString(testClientProof) + if err != nil { + t.Fatal("Expected no error but have ", err) + } + if !bytes.Equal(proofs.ClientProof, expectedProof) { + t.Fatalf("Expected client proof\n\t'%s'\nbut have\n\t'%s'", + testClientProof, + base64.StdEncoding.EncodeToString(proofs.ClientProof), + ) + } +} diff --git a/pkg/updates/bridge_pubkey.gpg b/pkg/updates/bridge_pubkey.gpg new file mode 100644 index 00000000..dfa40e8c --- /dev/null +++ b/pkg/updates/bridge_pubkey.gpg @@ -0,0 +1,53 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +qdwhrw== +=B6/F +-----END PGP PUBLIC KEY BLOCK----- diff --git a/pkg/updates/compare_versions.go b/pkg/updates/compare_versions.go new file mode 100644 index 00000000..d1a963c5 --- /dev/null +++ b/pkg/updates/compare_versions.go @@ -0,0 +1,102 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package updates + +import ( + "regexp" + "strconv" + "strings" +) + +var nonVersionChars = regexp.MustCompile(`([^0-9.]+)`) //nolint[gochecknoglobals] + +// sanitizeVersion returns only numbers and periods. +func sanitizeVersion(version string) string { + return nonVersionChars.ReplaceAllString(version, "") +} + +// Result can be false positive, but must not be false negative. +// Assuming +// * dot separated integers format e.g. "A.B.C.…" where A,B,C,… are integers +// * `1.1` == `1.1.0` (i.e. first is not newer) +// * `1.1.1` > `1.1` (i.e. first is newer) +func isFirstVersionNewer(first, second string) (firstIsNewer bool, err error) { + first = sanitizeVersion(first) + second = sanitizeVersion(second) + + firstIsNewer, err = false, nil + if first == second { + return + } + + firstIsNewer = true + var firstArr, secondArr []int + if firstArr, err = versionStrToInts(first); err != nil { + return + } + if secondArr, err = versionStrToInts(second); err != nil { + return + } + + verLength := max(len(firstArr), len(secondArr)) + firstArr = appendZeros(firstArr, verLength) + secondArr = appendZeros(secondArr, verLength) + + for i := 0; i < verLength; i++ { + if firstArr[i] == secondArr[i] { + continue + } + return firstArr[i] > secondArr[i], nil + } + return false, nil +} + +func versionStrToInts(version string) (intArr []int, err error) { + strArr := strings.Split(version, ".") + intArr = make([]int, len(strArr)) + for index, item := range strArr { + if item == "" { + intArr[index] = 0 + continue + } + intArr[index], err = strconv.Atoi(item) + if err != nil { + return + } + } + return +} + +func appendZeros(ints []int, newsize int) []int { + size := len(ints) + if size >= newsize { + return ints + } + zeros := make([]int, newsize-size) + return append(ints, zeros...) +} + +func max(ints ...int) (max int) { + max = ints[0] + for _, a := range ints { + if max < a { + max = a + } + } + return +} diff --git a/pkg/updates/compare_versions_test.go b/pkg/updates/compare_versions_test.go new file mode 100644 index 00000000..1d2fb089 --- /dev/null +++ b/pkg/updates/compare_versions_test.go @@ -0,0 +1,78 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package updates + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +type testDataValues struct { + expectErr, expectedNewer bool + first, second string +} +type testDataList []testDataValues + +func (tdl *testDataList) add(err, newer bool, first, second string) { //nolint[unparam] + *tdl = append(*tdl, testDataValues{err, newer, first, second}) +} + +func (tdl *testDataList) addFirstIsNewer(first, second string) { + tdl.add(false, true, first, second) + tdl.add(false, false, second, first) +} + +func TestCompareVersion(t *testing.T) { + testData := testDataList{} + // same is never newer + testData.add(false, false, "1.1.1", "1.1.1") + testData.add(false, false, "1.1.0", "1.1") + testData.add(false, false, "1.0.0", "1") + testData.add(false, false, ".1.1", "0.1.1") + testData.add(false, false, "0.1.1", ".1.1") + + testData.addFirstIsNewer("1.1.10", "1.1.1") + testData.addFirstIsNewer("1.10.1", "1.1.1") + testData.addFirstIsNewer("10.1.1", "1.1.1") + + testData.addFirstIsNewer("1.1.1", "0.1.1") + testData.addFirstIsNewer("1.1.1", "1.0.1") + testData.addFirstIsNewer("1.1.1", "1.1.0") + + testData.addFirstIsNewer("1.1.1", "1") + testData.addFirstIsNewer("1.1.1", "1.1") + testData.addFirstIsNewer("1.1.1.1", "1.1.1") + + testData.addFirstIsNewer("1.1.1 beta", "1.1.0") + testData.addFirstIsNewer("1z.1z.1z", "1.1.0") + testData.addFirstIsNewer("1a.1b.1c", "1.1.0") + + for _, td := range testData { + t.Log(td) + isNewer, err := isFirstVersionNewer(td.first, td.second) + if td.expectErr { + require.True(t, err != nil, "expected error but got nil for %#v", td) + require.True(t, true == isNewer, "error expected but first is not newer for %#v", td) + continue + } + + require.True(t, err == nil, "expected no error but have %v for %#v", err, td) + require.True(t, isNewer == td.expectedNewer, "expected %v but have %v for %#v", td.expectedNewer, isNewer, err, td) + } +} diff --git a/pkg/updates/downloader.go b/pkg/updates/downloader.go new file mode 100644 index 00000000..d189f9d5 --- /dev/null +++ b/pkg/updates/downloader.go @@ -0,0 +1,131 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package updates + +import ( + "errors" + "io" + "io/ioutil" + "net/http" + "os" + "path/filepath" + "strconv" + + "github.com/ProtonMail/proton-bridge/pkg/dialer" +) + +func mkdirAllClear(path string) error { + if err := os.RemoveAll(path); err != nil { + return err + } + return os.MkdirAll(path, 0750) +} + +func downloadToBytes(path string) (out []byte, err error) { + var ( + client *http.Client + response *http.Response + ) + client = dialer.DialTimeoutClient() + log.WithField("path", path).Trace("Downloading") + + response, err = client.Get(path) + if err != nil { + return + } + out, err = ioutil.ReadAll(response.Body) + _ = response.Body.Close() + if response.StatusCode < http.StatusOK || http.StatusIMUsed < response.StatusCode { + err = errors.New(path + " " + response.Status) + } + return +} + +func downloadWithProgress(status *Progress, sourceURL, targetPath string) (err error) { + targetFile, err := os.Create(targetPath) + if err != nil { + log.Warnf("Cannot create update file %s: %v", targetPath, err) + return + } + defer targetFile.Close() //nolint[errcheck] + + var ( + client *http.Client + response *http.Response + ) + client = dialer.DialTimeoutClient() + response, err = client.Get(sourceURL) + if err != nil { + return + } + defer response.Body.Close() //nolint[errcheck] + + contentLength, _ := strconv.ParseUint(response.Header.Get("Content-Length"), 10, 64) + + wc := WriteCounter{ + Status: status, + Target: targetFile, + Size: contentLength, + } + + err = wc.ReadAll(response.Body) + return +} + +func downloadWithSignature(status *Progress, sourceURL, targetDir string) (localPath string, err error) { + localPath = filepath.Join(targetDir, filepath.Base(sourceURL)) + + if err = downloadWithProgress(nil, sourceURL+sigExtension, localPath+sigExtension); err != nil { + return + } + + if err = downloadWithProgress(status, sourceURL, localPath); err != nil { + return + } + return +} + +type WriteCounter struct { + Status *Progress + Target io.Writer + processed, Size, counter uint64 +} + +func (s *WriteCounter) ReadAll(source io.Reader) (err error) { + s.counter = uint64(0) + if s.Target == nil { + return errors.New("can not read all, target unset") + } + if source == nil { + return errors.New("can not read all, source unset") + } + _, err = io.Copy(s.Target, io.TeeReader(source, s)) + return +} + +func (s *WriteCounter) Write(p []byte) (int, error) { + if s.Status != nil && s.Size != 0 { + s.processed += uint64(len(p)) + fraction := float32(s.processed) / float32(s.Size) + if s.counter%uint64(100) == 0 || fraction == 1. { + s.Status.UpdateProcessed(fraction) + } + } + s.counter++ + return len(p), nil +} diff --git a/pkg/updates/progress.go b/pkg/updates/progress.go new file mode 100644 index 00000000..0aea62fa --- /dev/null +++ b/pkg/updates/progress.go @@ -0,0 +1,50 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package updates + +const ( + InfoCurrentVersion = 1 + iota + InfoDownloading + InfoVerifying + InfoUnpacking + InfoUpgrading + InfoQuitApp + InfoRestartApp +) + +type Progress struct { + Processed float32 // fraction of finished procedure [0.0-1.0] + Description int // description by code (needs to be translated anyway) + Err error // occurred error + channel chan<- Progress +} + +func (s *Progress) Update() { + s.channel <- *s +} + +func (s *Progress) UpdateDescription(description int) { + s.Description = description + s.Processed = 0 + s.Update() +} + +func (s *Progress) UpdateProcessed(processed float32) { + s.Processed = processed + s.Update() +} diff --git a/pkg/updates/signature.go b/pkg/updates/signature.go new file mode 100644 index 00000000..3df70bd4 --- /dev/null +++ b/pkg/updates/signature.go @@ -0,0 +1,108 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package updates + +import ( + "bytes" + "encoding/hex" + "errors" + "io" + "os" + "os/exec" + "runtime" + + "golang.org/x/crypto/openpgp" +) + +// gpg --export D51E64D3E63EDC3EEF7864CEE2C75D68E6234B07 | xxd -p | tr -d '\n' | xclip +const ( + keyID = "D51E64D3E63EDC3EEF7864CEE2C75D68E6234B07" + pubkeyHex = "99020d045a3d39e1011000be7cfacb714058f9851ce5888cad8250ea882b2563060b4d21f5f02fdcfb2b1e4073d33edc4050f235d35ab689050ed6435f5d79334bf30f093936472398eec259b7fa9c265cc18a8e4ab0681fcc0d4fdb934bfea9477007935af70bfdfa406de25b1e96838c9c4645d6613a13dffca1e70684e416cb8bff5348101d7c9cd3cb1b78229646dce4cc9cec2ecfa78d456547900e3c089c9d592e8cf33fa322c07016ae273880a1bcb8c8178d8fb804f555a8826129b2e2c535631c56a1fe73f476345e0851a5deda508833008b1751b6845e1ff788264350c3792f0932027fabe63dd230dce4da1b45f15eea584f25758355ae9784c32a2bd31d70333a5b6ff0b863cc177bcacfd35774029887551113cec424d9eb1f5ee4ab042b69c8b73a113d6596e88bdac55451e9403ee7944253b26177cbd97f79f22d138010a2e9044f5f16cf8c23ec7755332cf09250d50efbfedfb256426b1dba775af591b1f324b00dd497abeabc681036848954825139c7832902ab9ace0b73d270611d39a222e07e5ce98159acb99dcf8d3d62624458b2883d5feee43a48f981a601f01ebeb8e430181004e9990fc1a9d7d2e746d9aa8d5876ad576bf327399c08e834e6706a73300f9bc258f51510b597b9b34506ff21a993311d9a961ab07cc7c86476088d9aecaab31cc198e1091d62e5bdb161bc784879d4fca5a53fb292ffa89996a77101e10011010001b44c50726f746f6e20546563686e6f6c6f67696573204147202850726f746f6e4d61696c2042726964676520646576656c6f7065727329203c6272696467654070726f746f6e6d61696c2e63683e89025404130108003e021b03050b09080702061508090a0b020416020301021e01021780162104d51e64d3e63edc3eef7864cee2c75d68e6234b0705025bf7efbc050903bcdedb000a0910e2c75d68e6234b07e19e0fff58d859aabd12b6e37829b61d406f8563e68b8a32e18952378b88fac148acfbb51614d31419e3ec054ac5ef33abce8e15a75e85491861a6f7886c3315260e321a5cf037e8d9afd141bc5f3391b3aa8da8e44c5deb3b2a92402a5956079a991b7a7388064133b07f0c41e0ae66560e2bbfae484882a32d4b42676ee0a43e9e26cdb92e1c9942723ae491e91c39156abee84369db5afd2d9f6c2bad428113d851339267d7119a1d892b6313de1cd0c7aeac495036cf7c985c6b9e0cf7d6ecf50ba6dca913d56594e7fd2dac0d15f1e5196bddf3c9d2cdca724972a6ea294601bce9e9ccbff497785d7df9a8969517585ae514f94e2d54252b829cea842a4b62960724901e0f64953b68d69f5ca4d644dec3b9f947e57b25d6d1d37a0dc53fc62c2860a27ffb1e09658b6fa87fe43fb82f7b6a339c4b0b330ea4d906683b51a2844a685e939ecece3af8447f606c78645da77b66e6627ce838e6416c65f0b0c65335a6bd4091db7356f3f638e320505cd739ab762d27b1b8e5bcbba011821b49bfd63f4c0ac06ebacd36adb25448436ba795424d9d66f413854b833f72ab9ed0a6fc52ec3df5d9d655d0d9c0c21c8323ee785c08af5d341bd1f0067dc81bb4a74aa496f575c00c6fd58e067a6b04aed72ec59b263e6fe6707702e59eafb361532241fe881642da91518d5b01c238701d7317062afcf2f4b1b7c01c3c7164417a5e18b9020d045a3d39e1011000b74f40e9514f5a261e58f19cdeb88c5b835d74886facea681bd4c1d6280e957675f466cc6925b02540ed61d70660d66d47bd6af80edce3bb00dc3afc9cf36233fe8f0226a4983b712a93a96f00d54e9361f1975545776bfa1b204b6d97b6c8f41b1436098bb01608c07a2ffa097f6a32907402d81c29aadc18faa3b0417963e956993d7738dcb0b55b98db333463f5224e5d284d236548ba87e62856e244f68ffe4fe8f1a2a151a5736c66a58e10a9821c3cfa4d3b35893a7e0a867fe60d1e2a987a5c9f93b8da2a17de3dd48604167f68c73b2820191ec1363f7c95a65dffbe727a41383ad92c5fc6a1115cecc5bd29a7a5f6f990d09ee68ccd9b16cdf4f251f4ad1adf99d94b014e622a03efd1a9319e79d4aaf09ca1e905314443b4fdf3d3d2995ee38c9edf37e2eb5ded62def8b643e9dc6cfab3e1c83896f3413c05d973d49a3e73683ab6820dfae0fbfdfe60c1a65a5e1241d283bb7d6495568f87269ad606799ff5fae02aad4e712d0136f03ee008a9da1845a962406d44170b231a794d964d6233cb4150e92105172fd133c3b93b51c7b03623b4889cf5027f3ea483d2a37b2cc9648d1f00756bc0c66f798b91a9c4364e0e576c3a779eba69afb27dc8d630a850b4e293b88955d46e635577bdfb1802479f18dfe7da06b732f6ad0898a9c28086b0c8145c64dda245cea8a060301b812c29318f6c25a25d91f69f11001101000189023c041801080026021b0c162104d51e64d3e63edc3eef7864cee2c75d68e6234b0705025bf7f281050903bce1a0000a0910e2c75d68e6234b07b7930ffe31f08ec8ecc415eb767c8434a1262c528b8f5b7d77b293396b672ac06ea36bec9ac82b3817e45d474d4d091779be54692196930895317f8cbabd7f5bf3991ba23efbb1fe322d5d86e71c58619457a4c19917c789d2e62c23a4c0ede82d4445fb6ff99e721caf75999b2be9e35c7c0e9578874a0507a796d66f0d5b2a1d2544b03861b0a278ef7b5abb0b8f5e4b3b72aabcb6c4a05731f344ef8257a1d5d5bdccba2cb9640ef7442d68206613979cc8935334876148c827df7d3044859fdd00c12bcf072881303e100abe3a5ca94e2c36497643471e6c43c4fed35f24777cee259e0f873243a7343adbbce1cbed4ce073d838733f9e2a4e9281a5f43f2aac56ff0c472843a07814a5515ae809ed976d0699ebce1f5e5661fd6752f22af8521cc485ea2925bc8c650865dab398fbd64460fd873f687fd2b7db55d1920fd5787010063eba5d4b08fd9882e9c2244270886f8c6411194d4e55d207e374d6bf9ea3463ce4db2f2e6818f57ac964f76f79b1df0b9dc3e688f0c5d73f33010809f9ed2effc6e4387ce0f1eb634e7a67bf04e9e30126de137999e7fdea05b9ee6088154d8369e5fac81b7c0af16d6be8d636bd84348812333822319cd0e362ab06969032b57f9233e618ec9f67d5d65a52c51f5f942cf83f5522bfe3de3bab39b3de867f5f6a108e0b661789c2a049b990c812f2b1b1722d3e403299f969eb34a9dc21af" +) + +var ( + pubkeyRing = openpgp.EntityList{} //nolint[gochecknoglobals] +) + +func singAndVerify(pathToFile string) (err error) { + err = signFile(pathToFile) + if err != nil { + err = verifyFile(pathToFile) + } + return +} + +func signFile(pathToFile string) (err error) { + if runtime.GOOS != "linux" { //nolint[goconst] + return errors.New("tar not implemented only for linux") + } + // assuming gpg detach-sign creates file with suffix .sig by default. + // Lstat does not follow the link i.e. only link is deleted (not link target). + if _, err := os.Lstat(pathToFile + sigExtension); !os.IsNotExist(err) { + _ = os.Remove(pathToFile + sigExtension) + } + cmd := exec.Command("gpg", "--local-user", keyID, "--detach-sign", pathToFile) //nolint[gosec] + return cmd.Run() +} + +func verifyFile(pathToFile string) error { + fileReader, err := os.Open(pathToFile) //nolint[gosec] + if err != nil { + return err + } + defer fileReader.Close() //nolint[errcheck] + + signatureReader, err := os.Open(pathToFile + sigExtension) //nolint[gosec] + if err != nil { + return err + } + defer signatureReader.Close() //nolint[errcheck] + + return verifyBytes(fileReader, signatureReader) +} + +func verifyBytes(fileReader, signatureReader io.Reader) (err error) { + if _, err = getPubKey(); err != nil { + return err + } + + _, err = openpgp.CheckDetachedSignature(pubkeyRing, fileReader, signatureReader, nil) + /* + if err != nil { + return err + } + + if signer == nil || signer.PrimaryKey.KeyId != keyID { + return errors.New("Signer with wrong key ID") + } + */ + return +} + +// from opengpg/read_test.go +func getPubKey() (el openpgp.EntityList, err error) { + if pubkeyRing != nil && len(pubkeyRing) != 0 { + return pubkeyRing, nil + } + data, err := hex.DecodeString(pubkeyHex) + if err != nil { + return + } + pubkeyRing, err = openpgp.ReadKeyRing(bytes.NewBuffer(data)) + return pubkeyRing, err +} diff --git a/pkg/updates/sync.go b/pkg/updates/sync.go new file mode 100644 index 00000000..96572483 --- /dev/null +++ b/pkg/updates/sync.go @@ -0,0 +1,239 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package updates + +import ( + "crypto/sha256" + "errors" + "io" + "os" + "path/filepath" +) + +func syncFolders(localPath, updatePath string) (err error) { + backupDir := filepath.Join(filepath.Dir(updatePath), "backup") + if err = createBackup(localPath, backupDir); err != nil { + return + } + + if err = removeMissing(localPath, updatePath); err != nil { + restoreFromBackup(backupDir, localPath) + return + } + + if err = copyRecursively(updatePath, localPath); err != nil { + restoreFromBackup(backupDir, localPath) + return + } + + return nil +} + +func removeMissing(folderToCleanPath, itemsToKeepPath string) (err error) { + log.Debug("remove missing") + // Create list of files. + existingRelPaths := map[string]bool{} + err = filepath.Walk(itemsToKeepPath, func(keepThis string, _ os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + relPath, walkErr := filepath.Rel(itemsToKeepPath, keepThis) + if walkErr != nil { + return walkErr + } + log.Debug("path to keep ", relPath) + existingRelPaths[relPath] = true + return nil + }) + if err != nil { + return + } + + delList := []string{} + err = filepath.Walk(folderToCleanPath, func(removeThis string, _ os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + relPath, walkErr := filepath.Rel(folderToCleanPath, removeThis) + if walkErr != nil { + return walkErr + } + log.Debug("check path ", relPath) + if !existingRelPaths[relPath] { + log.Debug("path not in list, removing ", removeThis) + delList = append(delList, removeThis) + } + return nil + }) + if err != nil { + return + } + + for _, removeThis := range delList { + if err = os.RemoveAll(removeThis); err != nil && !os.IsNotExist(err) { + log.Error("remove error ", err) + return + } + } + + return nil +} + +func restoreFromBackup(backupDir, localPath string) { + log.Error("recovering from ", backupDir, " to ", localPath) + _ = copyRecursively(backupDir, localPath) +} + +func createBackup(srcFile, dstDir string) (err error) { + log.Debug("backup ", srcFile, " in ", dstDir) + if err = mkdirAllClear(dstDir); err != nil { + return + } + + return copyRecursively(srcFile, dstDir) +} + +// checksum assumes the file is a regular file and that it exists. +func checksum(path string) (hash string) { + file, err := os.Open(path) //nolint[gosec] + if err != nil { + return + } + defer file.Close() //nolint[errcheck] + + hasher := sha256.New() + if _, err := io.Copy(hasher, file); err != nil { + return + } + + return string(hasher.Sum(nil)) +} + +// srcDir including app folder. +// dstDir including app folder. +func copyRecursively(srcDir, dstDir string) error { // nolint[funlen] + return filepath.Walk(srcDir, func(srcPath string, srcInfo os.FileInfo, err error) error { + if err != nil { + return err + } + + srcIsLink := srcInfo.Mode()&os.ModeSymlink == os.ModeSymlink + srcIsDir := srcInfo.IsDir() + + // Non regular source (e.g. named pipes, sockets, devices...). + if !srcIsLink && !srcIsDir && !srcInfo.Mode().IsRegular() { + log.Error("File ", srcPath, " with mode ", srcInfo.Mode()) + return errors.New("irregular source file. Copy not implemented") + } + + // Destination path. + srcRelPath, err := filepath.Rel(srcDir, srcPath) + if err != nil { + return err + } + dstPath := filepath.Join(dstDir, srcRelPath) + log.Debug("src: ", srcPath, " dst: ", dstPath) + + // Destination exists. + dstInfo, err := os.Lstat(dstPath) + if err == nil { + dstIsLink := dstInfo.Mode()&os.ModeSymlink == os.ModeSymlink + dstIsDir := dstInfo.IsDir() + + // Non regular destination (e.g. named pipes, sockets, devices...). + if !dstIsLink && !dstIsDir && !dstInfo.Mode().IsRegular() { + log.Error("File ", dstPath, " with mode ", dstInfo.Mode()) + return errors.New("irregular target file. Copy not implemented") + } + + if dstIsLink { + if err = os.Remove(dstPath); err != nil { + return err + } + } + + if !dstIsLink && dstIsDir && !srcIsDir { + if err = os.RemoveAll(dstPath); err != nil { + return err + } + } + + // NOTE: Do not return if !dstIsLink && dstIsDir && srcIsDir: the permissions might change. + + if dstInfo.Mode().IsRegular() && !srcInfo.Mode().IsRegular() { + if err = os.Remove(dstPath); err != nil { + return err + } + } + } else if !os.IsNotExist(err) { + return err + } + + // Create symbolic link and return. + if srcIsLink { + log.Debug("It is a symlink") + linkPath, err := os.Readlink(srcPath) + if err != nil { + return err + } + log.Debug("link to ", linkPath) + return os.Symlink(linkPath, dstPath) + } + + // Create dir and return. + if srcIsDir { + log.Debug("It is a dir") + return os.MkdirAll(dstPath, srcInfo.Mode()) + } + + // Regular files only. + // If files are same return. + if os.SameFile(srcInfo, dstInfo) || checksum(srcPath) == checksum(dstPath) { + log.Debug("Same files, skip copy") + return nil + } + + // Create/overwrite regular file. + srcReader, err := os.Open(srcPath) //nolint[gosec] + if err != nil { + return err + } + defer srcReader.Close() //nolint[errcheck] + return copyToTmpFileRename(srcReader, dstPath, srcInfo.Mode()) + }) +} + +func copyToTmpFileRename(srcReader io.Reader, dstPath string, dstMode os.FileMode) error { + log.Debug("Tmp and rename ", dstPath) + tmpPath := dstPath + ".tmp" + if err := copyToFileTruncate(srcReader, tmpPath, dstMode); err != nil { + return err + } + return os.Rename(tmpPath, dstPath) +} + +func copyToFileTruncate(srcReader io.Reader, dstPath string, dstMode os.FileMode) error { + log.Debug("Copy and truncate ", dstPath) + dstWriter, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, dstMode) + if err != nil { + return err + } + defer dstWriter.Close() //nolint[errcheck] + _, err = io.Copy(dstWriter, srcReader) + return err +} diff --git a/pkg/updates/sync_test.go b/pkg/updates/sync_test.go new file mode 100644 index 00000000..0386d4d1 --- /dev/null +++ b/pkg/updates/sync_test.go @@ -0,0 +1,157 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package updates + +import ( + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/require" +) + +const ( + FileType = "File" + SymlinkType = "Symlink" + DirType = "Dir" + EmptyType = "Empty" + NewType = "New" +) + +func TestSyncFolder(t *testing.T) { + for _, srcType := range []string{EmptyType, FileType, SymlinkType, DirType} { + for _, dstType := range []string{EmptyType, FileType, SymlinkType, DirType} { + require.NoError(t, checkCopyWorks(srcType, dstType)) + log.Warn("OK: from ", srcType, " to ", dstType) + } + } +} + +func checkCopyWorks(srcType, dstType string) error { + dirName := "from_" + srcType + "_to_" + dstType + AppCacheDir := "/tmp" + srcDir := filepath.Join(AppCacheDir, "sync_src", dirName) + destDir := filepath.Join(AppCacheDir, "sync_dst", dirName) + + // clear before + log.Info("remove all ", srcDir) + err := os.RemoveAll(srcDir) + if err != nil { + return err + } + + log.Info("remove all ", destDir) + err = os.RemoveAll(destDir) + if err != nil { + return err + } + + // create + err = createTestFolder(srcDir, srcType) + if err != nil { + return err + } + + err = createTestFolder(destDir, dstType) + if err != nil { + return err + } + + // copy + log.Info("Sync from ", srcDir, " to ", destDir) + err = syncFolders(destDir, srcDir) + if err != nil { + return err + } + + // Check + log.Info("check ", srcDir, " and ", destDir) + err = checkThatFilesAreSame(srcDir, destDir) + if err != nil { + return err + } + + // clear after + log.Info("remove all ", srcDir) + err = os.RemoveAll(srcDir) + if err != nil { + return err + } + + log.Info("remove all ", destDir) + err = os.RemoveAll(destDir) + if err != nil { + return err + } + + return err +} + +func checkThatFilesAreSame(src, dst string) error { + cmd := exec.Command("diff", "-qr", src, dst) //nolint[gosec] + cmd.Stderr = log.WriterLevel(logrus.ErrorLevel) + cmd.Stdout = log.WriterLevel(logrus.InfoLevel) + return cmd.Run() +} + +func createTestFolder(dirPath, dirType string) error { + log.Info("creating folder ", dirPath, " type ", dirType) + if dirType == NewType { + return nil + } + + err := mkdirAllClear(dirPath) + if err != nil { + return err + } + + if dirType == EmptyType { + return nil + } + + path := filepath.Join(dirPath, "testpath") + switch dirType { + case FileType: + err = ioutil.WriteFile(path, []byte("This is a test"), 0640) + if err != nil { + return err + } + + case SymlinkType: + err = os.Symlink("../../", path) + if err != nil { + return err + } + + case DirType: + err = os.MkdirAll(path, 0750) + if err != nil { + return err + } + + err = ioutil.WriteFile(filepath.Join(path, "another_file"), []byte("This is a test"), 0640) + if err != nil { + return err + } + } + + return nil +} diff --git a/pkg/updates/tar.go b/pkg/updates/tar.go new file mode 100644 index 00000000..0fd2dd9f --- /dev/null +++ b/pkg/updates/tar.go @@ -0,0 +1,126 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package updates + +import ( + "archive/tar" + "compress/gzip" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + + "github.com/sirupsen/logrus" +) + +func createTar(tarPath, sourcePath string) error { //nolint[unused] + if runtime.GOOS != "linux" { + return errors.New("tar not implemented only for linux") + } + // Check whether it exists and is a directory. + if _, err := os.Lstat(sourcePath); err != nil { + return err + } + + absPath, err := filepath.Abs(tarPath) + if err != nil { + return err + } + + cmd := exec.Command("tar", "-zvcf", absPath, filepath.Base(sourcePath)) //nolint[gosec] + cmd.Dir = filepath.Dir(sourcePath) + cmd.Stderr = log.WriterLevel(logrus.ErrorLevel) + cmd.Stdout = log.WriterLevel(logrus.InfoLevel) + return cmd.Run() +} + +func untarToDir(tarPath, targetDir string, status *Progress) error { //nolint[funlen] + // Check whether it exists and is a directory. + if ls, err := os.Lstat(targetDir); err == nil { + if !ls.IsDir() { + return errors.New("not a dir") + } + } else { + return err + } + + tgzReader, err := os.Open(tarPath) //nolint[gosec] + if err != nil { + return err + } + defer tgzReader.Close() //nolint[errcheck] + + size := uint64(0) + if info, err := tgzReader.Stat(); err == nil { + size = uint64(info.Size()) + } + + wc := &WriteCounter{ + Status: status, + Size: size, + } + + tarReader, err := gzip.NewReader(io.TeeReader(tgzReader, wc)) + if err != nil { + return err + } + + fileReader := tar.NewReader(tarReader) + for { + header, err := fileReader.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + if header == nil { + continue + } + + targetFile := filepath.Join(targetDir, header.Name) + info := header.FileInfo() + + // Create symlink. + if header.Typeflag == tar.TypeSymlink { + if header.Linkname == "" { + return errors.New("missing linkname") + } + if err := os.Symlink(header.Linkname, targetFile); err != nil { + return err + } + continue + } + + // Handle case that it is a directory. + if info.IsDir() { + if err := os.MkdirAll(targetFile, info.Mode()); err != nil { + return err + } + continue + } + + // Handle case that it is a regular file. + if err := copyToFileTruncate(fileReader, targetFile, info.Mode()); err != nil { + return err + } + } + return nil +} diff --git a/pkg/updates/testdata/current_version_linux.json b/pkg/updates/testdata/current_version_linux.json new file mode 100644 index 00000000..9ed70664 --- /dev/null +++ b/pkg/updates/testdata/current_version_linux.json @@ -0,0 +1 @@ +{"Version":"1.1.6","ReleaseDate":"10 Jul 19 11:02 +0200","ReleaseNotes":"• Necessary updates reflecting API changes\n• Report wrongly formated messages\n","ReleaseFixedBugs":"• Fixed verification for contacts signed by older or missing key\n• Outlook always shows attachment icon\n","FixedBugs":["• Fixed verification for contacts signed by older or missing key","• Outlook always shows attachment icon",""],"URL":"https://protonmail.com/download/Bridge-Installer.sh","LandingPage":"https://protonmail.com/bridge/download","UpdateFile":"https://protonmail.com/download/bridge_upgrade_linux.tgz","InstallerFile":"https://protonmail.com/download/Bridge-Installer.sh","DebFile":"https://protonmail.com/download/protonmail-bridge_1.1.6-1_amd64.deb","RpmFile":"https://protonmail.com/download/protonmail-bridge-1.1.6-1.x86_64.rpm","PkgFile":"https://protonmail.com/download/PKGBUILD"} \ No newline at end of file diff --git a/pkg/updates/testdata/current_version_linux.json.sig b/pkg/updates/testdata/current_version_linux.json.sig new file mode 100644 index 0000000000000000000000000000000000000000..2f82c56a9d1537ff0c263d78197e099297166043 GIT binary patch literal 566 zcmV-60?GY}0y6{v0SEvc79j-H9%R$zKHNU2(ea{N%wY9=n!TavXDU2LKqdM>EgM6#0fW1xPoANVTFh zAK2b!UMeVSvD$PgA#V-F`Q!E8MnMO^yLu^87N?|3<1%zvrmBph!56Nb_>oreR^Jr& zrBrKUMwBHmzC?B2x~5_4Y2ysmk>Kge7bof9UphM8mY__AFNy>dtN1+TU2a%<&i4@c zE=2L{;mH{rI6;Q1Zt}ch_JNO@;Mw_X(CzH~ve%&3>DPsD>nh0PSn>XLzms*|gSfge zfyI++QX6}Zj<3y%2mflhg~p|0W{Lr-K1Iu2eSKG!QH0qSR~~~2m_w4)Cv!)AVoSO> z2M^_%CkParR*Jc>W{#t2e|=GW9=Htv<-;UDrX}&!*W|vz82r08)IhAX?WI)!-R)=9 z@T0sBJ@Q91x4YToPC#lJf%uun46xG|Ga-EoRB_c(v|<2k=oOpkUT|Bp!#THxw&}q0 z4u)gvF*}gT##DYKAgy*!ZF-gFL7zI2V-i`wn)XuJGPA;Rr&*YPD&n!r5zr8$!hUpK E@ZBmMQUCw| literal 0 HcmV?d00001 diff --git a/pkg/updates/updates.go b/pkg/updates/updates.go new file mode 100644 index 00000000..852eca83 --- /dev/null +++ b/pkg/updates/updates.go @@ -0,0 +1,310 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package updates + +import ( + "bytes" + "encoding/json" + "errors" + "io/ioutil" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/ProtonMail/proton-bridge/pkg/config" + "github.com/kardianos/osext" +) + +const ( + sigExtension = ".sig" +) + +var ( + Host = "https://protonmail.com" //nolint[gochecknoglobals] + DownloadPath = "download" //nolint[gochecknoglobals] + + // BuildType specifies type of build (e.g. QA or beta). + BuildType = "" //nolint[gochecknoglobals] +) + +var ( + log = config.GetLogEntry("bridgeUtils/updates") //nolint[gochecknoglobals] + + installFileSuffix = map[string]string{ //nolint[gochecknoglobals] + "darwin": ".dmg", + "windows": ".exe", + "linux": ".sh", + } + + ErrDownloadFailed = errors.New("error happened during download") //nolint[gochecknoglobals] + ErrUpdateVerifyFailed = errors.New("cannot verify signature") //nolint[gochecknoglobals] +) + +type Updates struct { + appName string + version string + revision string + buildTime string + releaseNotes string + releaseFixedBugs string + updateTempDir string + landingPagePath string // Based on Host/; default landing page for download. + installerFileBaseName string // File for initial install or manual reinstall. per goos [exe, dmg, sh]. + versionFileBaseName string // Text file containing information about current file. per goos [_linux,_darwin,_windows].json (have .sig file). + updateFileBaseName string // File for automatic update. per goos [_linux,_darwin,_windows].tgz (have .sig file). + macAppBundleName string // For update procedure. + cachedNewerVersion *VersionInfo // To have info about latest version even when the internet connection drops. +} + +// New inits Updates struct. +// `appName` should be in camelCase format for file names. For installer files is converted to CamelCase. +func New(appName, version, revision, buildTime, releaseNotes, releaseFixedBugs, updateTempDir string) *Updates { + return &Updates{ + appName: appName, + version: version, + revision: revision, + buildTime: buildTime, + releaseNotes: releaseNotes, + releaseFixedBugs: releaseFixedBugs, + updateTempDir: updateTempDir, + landingPagePath: appName + "/download", + installerFileBaseName: strings.Title(appName) + "-Installer", + versionFileBaseName: "current_version", + updateFileBaseName: appName + "_upgrade", + macAppBundleName: "ProtonMail " + strings.Title(appName) + ".app", // For update procedure. + } +} + +func (u *Updates) CreateJSONAndSign(deployDir, goos string) error { + versionInfo := u.getLocalVersion(goos) + versionInfo.Version = sanitizeVersion(versionInfo.Version) + + versionFileName := filepath.Base(u.versionFileURL(goos)) + versionFilePath := filepath.Join(deployDir, versionFileName) + + txt, err := json.Marshal(versionInfo) + if err != nil { + return err + } + + if err = ioutil.WriteFile(versionFilePath, txt, 0644); err != nil { + return err + } + + if err := singAndVerify(versionFilePath); err != nil { + return err + } + + updateFileName := filepath.Base(versionInfo.UpdateFile) + updateFilePath := filepath.Join(deployDir, updateFileName) + if err := singAndVerify(updateFilePath); err != nil { + return err + } + + return nil +} + +func (u *Updates) CheckIsBridgeUpToDate() (isUpToDate bool, latestVersion VersionInfo, err error) { + localVersion := u.GetLocalVersion() + latestVersion, err = u.getLatestVersion() + if err != nil { + return + } + + localIsOld, err := isFirstVersionNewer(latestVersion.Version, localVersion.Version) + return !localIsOld, latestVersion, err +} + +func (u *Updates) GetDownloadLink() string { + latestVersion, err := u.getLatestVersion() + if err != nil || latestVersion.InstallerFile == "" { + localVersion := u.GetLocalVersion() + return localVersion.GetDownloadLink() + } + return latestVersion.GetDownloadLink() +} + +func (u *Updates) GetLocalVersion() VersionInfo { + return u.getLocalVersion(runtime.GOOS) +} + +func (u *Updates) getLocalVersion(goos string) VersionInfo { + version := u.version + if BuildType != "" { + version += " " + BuildType + } + + versionInfo := VersionInfo{ + Version: version, + Revision: u.revision, + ReleaseDate: u.buildTime, + ReleaseNotes: u.releaseNotes, + ReleaseFixedBugs: u.releaseFixedBugs, + FixedBugs: strings.Split(u.releaseFixedBugs, "\n"), + URL: u.installerFileURL(goos), + + LandingPage: u.landingPageURL(), + UpdateFile: u.updateFileURL(goos), + InstallerFile: u.installerFileURL(goos), + } + + if goos == "linux" { + pkgName := "protonmail-" + u.appName + pkgRel := "1" + pkgBase := strings.Join([]string{Host, DownloadPath, pkgName}, "/") + + versionInfo.DebFile = pkgBase + "_" + u.version + "-" + pkgRel + "_amd64.deb" + versionInfo.RpmFile = pkgBase + "-" + u.version + "-" + pkgRel + ".x86_64.rpm" + versionInfo.PkgFile = strings.Join([]string{Host, DownloadPath, "PKGBUILD"}, "/") + } + + return versionInfo +} + +func (u *Updates) getLatestVersion() (latestVersion VersionInfo, err error) { + version, err := downloadToBytes(u.versionFileURL(runtime.GOOS)) + if err != nil { + if u.cachedNewerVersion != nil { + return *u.cachedNewerVersion, nil + } + return + } + + signature, err := downloadToBytes(u.signatureFileURL(runtime.GOOS)) + if err != nil { + if u.cachedNewerVersion != nil { + return *u.cachedNewerVersion, nil + } + return + } + + if err = verifyBytes(bytes.NewReader(version), bytes.NewReader(signature)); err != nil { + return + } + + if err = json.NewDecoder(bytes.NewReader(version)).Decode(&latestVersion); err != nil { + return + } + if localIsOld, _ := isFirstVersionNewer(latestVersion.Version, u.version); localIsOld { + u.cachedNewerVersion = &latestVersion + } + return +} + +func (u *Updates) landingPageURL() string { + return strings.Join([]string{Host, u.landingPagePath}, "/") +} + +func (u *Updates) signatureFileURL(goos string) string { + return u.versionFileURL(goos) + sigExtension +} + +func (u *Updates) versionFileURL(goos string) string { + return strings.Join([]string{Host, DownloadPath, u.versionFileBaseName + "_" + goos + ".json"}, "/") +} + +func (u *Updates) installerFileURL(goos string) string { + return strings.Join([]string{Host, DownloadPath, u.installerFileBaseName + installFileSuffix[goos]}, "/") +} + +func (u *Updates) updateFileURL(goos string) string { + return strings.Join([]string{Host, DownloadPath, u.updateFileBaseName + "_" + goos + ".tgz"}, "/") +} + +func (u *Updates) StartUpgrade(currentStatus chan<- Progress) { // nolint[funlen] + status := &Progress{channel: currentStatus} + defer status.Update() + + // Get latest version. + var verInfo VersionInfo + status.UpdateDescription(InfoCurrentVersion) + if verInfo, status.Err = u.getLatestVersion(); status.Err != nil { + return + } + + if verInfo.UpdateFile == "" { + log.Warn("Empty update URL. Update manually.") + status.Err = ErrDownloadFailed + return + } + + // Download. + status.UpdateDescription(InfoDownloading) + if status.Err = mkdirAllClear(u.updateTempDir); status.Err != nil { + return + } + var updateTar string + updateTar, status.Err = downloadWithSignature( + status, + verInfo.UpdateFile, + u.updateTempDir, + ) + if status.Err != nil { + return + } + + // Check signature. + status.UpdateDescription(InfoVerifying) + status.Err = verifyFile(updateTar) + if status.Err != nil { + log.Warnf("Cannot verify update file %s: %v", updateTar, status.Err) + status.Err = ErrUpdateVerifyFailed + return + } + + // Untar. + status.UpdateDescription(InfoUnpacking) + status.Err = untarToDir(updateTar, u.updateTempDir, status) + if status.Err != nil { + return + } + + // Run upgrade (OS specific). + status.UpdateDescription(InfoUpgrading) + switch runtime.GOOS { + case "windows": //nolint[goconst] + cmd := exec.Command("./" + u.installerFileBaseName) // nolint[gosec] + cmd.Dir = u.updateTempDir + status.Err = cmd.Start() + case "darwin": + // current path is better then appDir = filepath.Join("/Applications") + var exePath string + exePath, status.Err = osext.Executable() + if status.Err != nil { + return + } + localPath := filepath.Dir(exePath) // Macos + localPath = filepath.Dir(localPath) // Contents + localPath = filepath.Dir(localPath) // .app + + updatePath := filepath.Join(u.updateTempDir, u.macAppBundleName) + log.Warn("localPath ", localPath) + log.Warn("updatePath ", updatePath) + status.Err = syncFolders(localPath, updatePath) + if status.Err != nil { + return + } + status.UpdateDescription(InfoRestartApp) + return + default: + status.Err = errors.New("upgrade for " + runtime.GOOS + " not implemented") + } + + status.UpdateDescription(InfoQuitApp) +} diff --git a/pkg/updates/updates_beta.go b/pkg/updates/updates_beta.go new file mode 100644 index 00000000..a6252438 --- /dev/null +++ b/pkg/updates/updates_beta.go @@ -0,0 +1,25 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build build_beta + +package updates + +func init() { + DownloadPath = "download/beta" + BuildType = "beta" +} diff --git a/pkg/updates/updates_qa.go b/pkg/updates/updates_qa.go new file mode 100644 index 00000000..cdf070dc --- /dev/null +++ b/pkg/updates/updates_qa.go @@ -0,0 +1,26 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build build_qa + +package updates + +func init() { + Host = "https://bridgeteam.protontech.ch" + DownloadPath = "download/qa" + BuildType = "QA" +} diff --git a/pkg/updates/updates_test.go b/pkg/updates/updates_test.go new file mode 100644 index 00000000..4482c0e2 --- /dev/null +++ b/pkg/updates/updates_test.go @@ -0,0 +1,178 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package updates + +import ( + "io/ioutil" + "net/http" + "os" + "runtime" + "testing" + + "github.com/stretchr/testify/require" +) + +const testServerPort = "8999" + +var testUpdateDir string //nolint[gochecknoglobals] + +func TestMain(m *testing.M) { + setup() + code := m.Run() + shutdown() + os.Exit(code) +} + +func setup() { + var err error + testUpdateDir, err = ioutil.TempDir("", "upgrade") + if err != nil { + panic(err) + } + + Host = "http://localhost:" + testServerPort + go startServer() +} + +func shutdown() { + _ = os.RemoveAll(testUpdateDir) +} + +func startServer() { + http.HandleFunc("/download/current_version_linux.json", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "./testdata/current_version_linux.json") + }) + http.HandleFunc("/download/current_version_linux.json.sig", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "./testdata/current_version_linux.json.sig") + }) + http.HandleFunc("/download/current_version_darwin.json", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "./testdata/current_version_linux.json") + }) + http.HandleFunc("/download/current_version_darwin.json.sig", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "./testdata/current_version_linux.json.sig") + }) + panic(http.ListenAndServe(":"+testServerPort, nil)) +} + +func TestCheckBridgeIsUpToDate(t *testing.T) { + updates := newTestUpdates("1.1.6") + isUpToDate, _, err := updates.CheckIsBridgeUpToDate() + require.NoError(t, err) + require.True(t, isUpToDate, "Bridge should be up to date") +} + +func TestCheckBridgeIsNotUpToDate(t *testing.T) { + updates := newTestUpdates("1.1.5") + isUpToDate, _, err := updates.CheckIsBridgeUpToDate() + require.NoError(t, err) + require.True(t, !isUpToDate, "Bridge should not be up to date") +} + +func TestGetLocalVersion(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skipping test because local version for windows is currently not supported by tests.") + } + updates := newTestUpdates("1") + expectedVersion := VersionInfo{ + Version: "1", + Revision: "rev123", + ReleaseDate: "42", + ReleaseNotes: "• new feature", + ReleaseFixedBugs: "• fixed foo", + FixedBugs: []string{"• fixed foo"}, + URL: Host + "/" + DownloadPath + "/Bridge-Installer.sh", + + LandingPage: Host + "/bridge/download", + UpdateFile: Host + "/" + DownloadPath + "/bridge_upgrade_linux.tgz", + InstallerFile: Host + "/" + DownloadPath + "/Bridge-Installer.sh", + + DebFile: Host + "/" + DownloadPath + "/protonmail-bridge_1-1_amd64.deb", + RpmFile: Host + "/" + DownloadPath + "/protonmail-bridge-1-1.x86_64.rpm", + PkgFile: Host + "/" + DownloadPath + "/PKGBUILD", + } + if runtime.GOOS == "darwin" { + expectedVersion.URL = Host + "/" + DownloadPath + "/Bridge-Installer.dmg" + expectedVersion.UpdateFile = Host + "/" + DownloadPath + "/bridge_upgrade_darwin.tgz" + expectedVersion.InstallerFile = expectedVersion.URL + expectedVersion.DebFile = "" + expectedVersion.RpmFile = "" + expectedVersion.PkgFile = "" + } + version := updates.GetLocalVersion() + require.Equal(t, expectedVersion, version) +} + +func TestGetLatestVersion(t *testing.T) { + updates := newTestUpdates("1") + expectedVersion := VersionInfo{ + Version: "1.1.6", + Revision: "", + ReleaseDate: "10 Jul 19 11:02 +0200", + ReleaseNotes: "• Necessary updates reflecting API changes\n• Report wrongly formated messages\n", + ReleaseFixedBugs: "• Fixed verification for contacts signed by older or missing key\n• Outlook always shows attachment icon\n", + FixedBugs: []string{ + "• Fixed verification for contacts signed by older or missing key", + "• Outlook always shows attachment icon", + "", + }, + URL: "https://protonmail.com/download/Bridge-Installer.sh", + + LandingPage: "https://protonmail.com/bridge/download", + UpdateFile: "https://protonmail.com/download/bridge_upgrade_linux.tgz", + InstallerFile: "https://protonmail.com/download/Bridge-Installer.sh", + + DebFile: "https://protonmail.com/download/protonmail-bridge_1.1.6-1_amd64.deb", + RpmFile: "https://protonmail.com/download/protonmail-bridge-1.1.6-1.x86_64.rpm", + PkgFile: "https://protonmail.com/download/PKGBUILD", + } + version, err := updates.getLatestVersion() + require.NoError(t, err) + require.Equal(t, expectedVersion, version) +} + +func TestStartUpgrade(t *testing.T) { + if testing.Short() { + t.Skip("skipping test in short mode.") + } + if runtime.GOOS != "windows" { + t.Skip("skipping test because only upgrading on windows is currently supported by tests.") + } + + updates := newTestUpdates("1") + progress := make(chan Progress, 1) + done := make(chan error) + + go func() { + for current := range progress { + log.Infof("progress descr: %d processed %f err %v", current.Description, current.Processed, current.Err) + if current.Err != nil { + done <- current.Err + break + } + } + done <- nil + }() + + updates.StartUpgrade(progress) + close(progress) + require.NoError(t, <-done) +} + +func newTestUpdates(version string) *Updates { + return New("bridge", version, "rev123", "42", "• new feature", "• fixed foo", testUpdateDir) +} diff --git a/pkg/updates/version_info.go b/pkg/updates/version_info.go new file mode 100644 index 00000000..2ebd1675 --- /dev/null +++ b/pkg/updates/version_info.go @@ -0,0 +1,49 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package updates + +import ( + "runtime" + "strings" +) + +type VersionInfo struct { + Version string + Revision string + ReleaseDate string // Timestamp generated automatically + ReleaseNotes string // List of features, new line separated with leading dot e.g. `• example\n` + ReleaseFixedBugs string // List of fixed bugs, same usage as release notes + FixedBugs []string // Deprecated list of fixed bugs keeping for backward compatibility (mandatory for working versions up to 1.1.5) + URL string // Open browser and download (obsolete replaced by InstallerFile) + + LandingPage string // landing page for manual download + UpdateFile string // automatic update file + InstallerFile string `json:",omitempty"` // manual update file + DebFile string `json:",omitempty"` // debian package file + RpmFile string `json:",omitempty"` // red hat package file + PkgFile string `json:",omitempty"` // arch PKGBUILD file +} + +func (info *VersionInfo) GetDownloadLink() string { + switch runtime.GOOS { + case "linux": + return strings.Join([]string{info.DebFile, info.RpmFile, info.PkgFile}, "\n") + default: + return info.InstallerFile + } +} diff --git a/pkg/useragent/useragent.go b/pkg/useragent/useragent.go new file mode 100644 index 00000000..95dc8490 --- /dev/null +++ b/pkg/useragent/useragent.go @@ -0,0 +1,57 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package useragent + +import ( + "fmt" + "os/exec" + "runtime" +) + +// IsCatalinaOrNewer checks that host is MacOS Catalina 10.14.xx or higher. +func IsCatalinaOrNewer() bool { + if runtime.GOOS != "darwin" { + return false + } + major, minor, _ := getMacVersion() + return isVersionCatalinaOrNewer(major, minor) +} + +func getMacVersion() (major, minor, tiny int) { + major, minor, tiny = 10, 0, 0 + out, err := exec.Command("sw_vers", "-productVersion").Output() + if err != nil { + return + } + return parseMacVersion(string(out)) +} + +func parseMacVersion(version string) (major, minor, tiny int) { + _, _ = fmt.Sscanf(version, "%d.%d.%d", &major, &minor, &tiny) + return +} + +func isVersionCatalinaOrNewer(major, minor int) bool { + if major != 10 { + return false + } + if minor < 15 { + return false + } + return true +} diff --git a/pkg/useragent/useragent_test.go b/pkg/useragent/useragent_test.go new file mode 100644 index 00000000..17ef1965 --- /dev/null +++ b/pkg/useragent/useragent_test.go @@ -0,0 +1,57 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package useragent + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetMacVersion(t *testing.T) { + testData := map[string]struct{ major, minor, tiny int }{ + "10.14.4": {10, 14, 4}, + "10.14.4\r\n": {10, 14, 4}, + "10.14.0": {10, 14, 0}, + "10.14": {10, 14, 0}, + "10": {10, 0, 0}, + } + + for arg, exp := range testData { + gotMajor, gotMinor, gotTiny := parseMacVersion(arg) + assert.Equal(t, exp.major, gotMajor, "arg %q", arg) + assert.Equal(t, exp.minor, gotMinor, "arg %q", arg) + assert.Equal(t, exp.tiny, gotTiny, "arg %q", arg) + } +} + +func TestIsVersionCatalinaOrNewer(t *testing.T) { + testData := map[struct{ major, minor int }]bool{ + {9, 0}: false, + {9, 15}: false, + {10, 13}: false, + {10, 14}: false, + {10, 15}: true, + {10, 16}: true, + } + + for args, exp := range testData { + got := isVersionCatalinaOrNewer(args.major, args.minor) + assert.Equal(t, exp, got, "version %q.%q", args.major, args.minor) + } +} diff --git a/release-notes/bugs.txt b/release-notes/bugs.txt new file mode 100644 index 00000000..ec38cd80 --- /dev/null +++ b/release-notes/bugs.txt @@ -0,0 +1,2 @@ +• Fixed rare case of sending the same message multiple times in Outlook +• Fixed bug in macOS update process; available from next update diff --git a/release-notes/notes.txt b/release-notes/notes.txt new file mode 100644 index 00000000..a84bced0 --- /dev/null +++ b/release-notes/notes.txt @@ -0,0 +1,8 @@ +NOTE: We recommend to reconfigure your email client after upgrading to ensure the best results with the new draft folder support + +• Faster and more resilient mail synchronization process, especially for large mailboxes +• Added "Alternate Routing" feature to mitigate blocking of Proton Servers +• Added synchronization of draft folder +• Improved event handling when there are frequent changes +• Security improvements for loading dependent libraries +• Minor UI & API communication tweaks diff --git a/test/Makefile b/test/Makefile new file mode 100644 index 00000000..f58a9abc --- /dev/null +++ b/test/Makefile @@ -0,0 +1,41 @@ +.PHONY: check-has-go install-godog test test-live test-debug test-live-debug + +export GO111MODULE=on +export VERSION:=1.2.5-integrationtest +export VERBOSITY?=fatal +export TEST_DATA=testdata + +check-has-go: + @which go || (echo "Install Go-lang!" && exit 1) + +install-godog: check-has-go + go get github.com/cucumber/godog/cmd/godog@v0.8.1 + +test: + which godog || $(MAKE) install-godog + TEST_ENV=fake TEST_ACCOUNTS=accounts/fake.json godog --tags="~@ignore" $(FEATURES) + +# Doesn't work in parallel! +# Provide TEST_ACCOUNTS with your accounts. +test-live: + which godog || $(MAKE) install-godog + TEST_ENV=live godog --tags="~@ignore && ~@ignore-live" $(FEATURES) + +# Doesn't work in parallel! +# Provide TEST_ACCOUNTS with your accounts. +# We need to pass build tag which is not possible with godog command. +# Tests against staging env are intended for debug purposes or checking new changes on API. +test-stage: + TEST_ENV=live go test -tags=$(TAGS) -- $(FEATURES) + +test-debug: + TEST_ENV=fake dlv test -- $(FEATURES) + +test-live-debug: + TEST_ENV=live dlv test -- $(FEATURES) + +# -run flag is not working anyway, but lets keep it there to note we really do not want to run tests. +# To properly benchmark sync/fetch, we need everything empty. For that is better to start everything +# again and safest way is to run only one loop per run. +bench: + TEST_DATA=../testdata go test -run='^$$' -bench=. -benchtime=1x -timeout=60m ./benchmarks/... diff --git a/test/README.md b/test/README.md new file mode 100644 index 00000000..0c1e3ef7 --- /dev/null +++ b/test/README.md @@ -0,0 +1,130 @@ +# Integration tests + +This folder contains integration tests of the Bridge app. + +## What and how we are testing + +```mermaid +graph LR + S[Server] + C[Client] + U[User] + Creds[Credentials store] + + subgraph "Bridge app" + Core[Bridge core] + Store + Frontend["Qt / CLI"] + IMAP + SMTP + API[PMAPI] + + IMAP --> Core + SMTP --> Core + Frontend --> Core + Store --> Core + Core --> API + end + + C --> IMAP + C --> SMTP + U --> Frontend + API --> S + Core --> Creds +``` + +We want to test Bridge app from outside as much as possible. So we mock server (API), +credentials store and call commands to IMAP or SMTP the same way as client would do. + +## Example test + +BDD test in gherkin (cucumber) format (https://cucumber.io/docs/gherkin/reference/). + +``` +Feature: IMAP update messages + Background: + Given there is connected user "user" + And there are messages in mailbox "INBOX" for "user" + | from | to | subject | body | read | starred | + | john.doe@mail.com | user@pm.me | foo | hello | false | false | + | jane.doe@mail.com | name@pm.me | bar | world | true | true | + And there is IMAP client logged in as "user" + And there is IMAP client selected in "INBOX" + + Scenario: Mark message as read + When IMAP client marks message "1" as read + Then IMAP response is "OK" + And message "1" in "INBOX" for "user" is marked as read + And message "1" in "INBOX" for "user" is marked as unstarred +``` + +Is translated into code with godog (https://github.com/cucumber/godog/). + +```go +// Registration +func FeatureContext(s *godog.Suite) { + s.Step(`^there is connected user "([^"]*)"$`, thereIsConnectedUser) +} + +// Godog step function +func thereIsConnectedUser(username string) error { + account := ctx.GetTestAccount(username) + if account == nil { + return godog.ErrPending + } + ctx.GetPMAPIController().AddUser(account.User, account.Addresses) + return ctx.LoginUser(account.Username(), account.Password(), account.MailboxPassword()) +} +``` + +## BDD + +BDD has three parts: + +* `Given` (setup), +* `When` (action) +* and `Then` (check). + +Setup has to prepare context and always end without error. Action, on +the other hand, needs to always end without error, but store it in +the context. Check should analyze the status of the bridge, store or +API and also check whether something failed before. + +Therefore we cannot use a sentence such as `there is user` for both +setup and check steps. We always begin setup steps with `there is/are`, +while check steps are written in the form `something is/has feature`. +Actions are written in the form `something does action`. By doing this +we can always be sure what each steps does or should do. + +In the code, we separate those parts in its own files to make sure +it's clear how the function should be implemented. + +## API faked by fakeapi or liveapi + +We need to control what server returns. Instead of using raw JSONs, +we fake the whole pmapi for local testing. Fake pmapi behaves as much +as possible the same way as real server, but does not follow every +single detail. Otherwise we would end up with writing complete server. :-) + +For both -- fake local pmapi and real live server -- we use controller. +Controller is available on test context and does setup like setting up +internet connection, user settings, labels or messages. + +Accounts for each environment are set up in `accounts` folder. Each +test function should use `TestAccount` object obtained by test ID +(such as `user` or `userMultipleAddress` for users, or `primary` +or `secondary` for addresses) and use available functions to get real +IDs (even if fake API uses the test IDs as real ones). + +Testing against live is using real users and doesn't work in parallel. +Only one job against live at a time can be running. + +## External e-mail accounts + +We have some external accounts which we are using for testing: + +* pm.bridge.qa@gmail.com +* bridge-qa@yandex.ru +* bridgeqa@seznam.cz + +For access, ask bridge team. diff --git a/test/accounts/account.go b/test/accounts/account.go new file mode 100644 index 00000000..f3951c16 --- /dev/null +++ b/test/accounts/account.go @@ -0,0 +1,192 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge.Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package accounts + +import ( + "encoding/json" + "io/ioutil" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" +) + +const ( + testUserKey = "user_key.json" + testAddressKey = "address_key.json" + testKeyPassphrase = "testpassphrase" +) + +type TestAccount struct { + user *pmapi.User + addressToBeUsed *pmapi.Address + addressesByBDDAddressID map[string]*pmapi.Address + password string + mailboxPassword string + twoFAEnabled bool +} + +func newTestAccount( + user *pmapi.User, + addressesByBDDAddressID map[string]*pmapi.Address, + addressIDToBeUsed string, + password, + mailboxPassword string, + twoFAEnabled bool, +) *TestAccount { + account := &TestAccount{ + user: user, + addressesByBDDAddressID: addressesByBDDAddressID, + password: password, + mailboxPassword: mailboxPassword, + twoFAEnabled: twoFAEnabled, + } + + if addressIDToBeUsed == "" { + account.addressToBeUsed = account.Addresses().Main() + } else { + for addressID, address := range addressesByBDDAddressID { + if addressID == addressIDToBeUsed { + account.addressToBeUsed = address + } + } + } + if account.addressToBeUsed == nil { + // Return nothing which will be interpreted as not implemented the same way the whole account. + return nil + } + + account.initKeys() + return account +} + +func (a *TestAccount) initKeys() { + if a.user.Keys.Keys != nil { + return + } + userKeys := loadPMKeys(readTestFile(testUserKey)) + _ = userKeys.KeyRing.Unlock([]byte(testKeyPassphrase)) + + addressKeys := loadPMKeys(readTestFile(testAddressKey)) + _ = addressKeys.KeyRing.Unlock([]byte(testKeyPassphrase)) + + a.user.Keys = *userKeys + for _, addressEmail := range a.Addresses().ActiveEmails() { + a.Addresses().ByEmail(addressEmail).Keys = *addressKeys + } +} + +func readTestFile(fileName string) []byte { + testDataFolder := os.Getenv("TEST_DATA") + path := filepath.Join(testDataFolder, fileName) + data, err := ioutil.ReadFile(path) //nolint[gosec] + if err != nil { + panic(err) + } + return data +} + +func loadPMKeys(jsonKeys []byte) (keys *pmapi.PMKeys) { + _ = json.Unmarshal(jsonKeys, &keys) + return +} + +func (a *TestAccount) User() *pmapi.User { + return a.user +} + +func (a *TestAccount) UserID() string { + return a.user.ID +} + +func (a *TestAccount) Username() string { + return a.user.Name +} + +func (a *TestAccount) Addresses() *pmapi.AddressList { + addressArray := []*pmapi.Address{} + for _, address := range a.addressesByBDDAddressID { + addressArray = append(addressArray, address) + } + // The order of addresses is important in PMAPI because the primary + // address is always the first in array. We are using map to define + // testing addresses which can cause random re-schuffle between tests + sort.SliceStable( + addressArray, + func(i, j int) bool { + return addressArray[i].Order < addressArray[j].Order + }, + ) + addresses := pmapi.AddressList(addressArray) + return &addresses +} + +func (a *TestAccount) Address() string { + return a.addressToBeUsed.Email +} + +func (a *TestAccount) AddressID() string { + return a.addressToBeUsed.ID +} + +// EnsureAddressID accepts address (simply the address) or bddAddressID used +// in tests (in format [bddAddressID]) and returns always the real address ID. +// If the address is not found, the ID of main address is returned. +func (a *TestAccount) EnsureAddressID(addressOrAddressTestID string) string { + if strings.HasPrefix(addressOrAddressTestID, "[") { + addressTestID := addressOrAddressTestID[1 : len(addressOrAddressTestID)-1] + address := a.addressesByBDDAddressID[addressTestID] + return address.ID + } + for _, address := range a.addressesByBDDAddressID { + if address.Email == addressOrAddressTestID { + return address.ID + } + } + return a.AddressID() +} + +// EnsureAddress accepts address (simply the address) or bddAddressID used +// in tests (in format [bddAddressID]) and returns always the address. +// If the address ID cannot be found, the original value is returned. +func (a *TestAccount) EnsureAddress(addressOrAddressTestID string) string { + if strings.HasPrefix(addressOrAddressTestID, "[") { + addressTestID := addressOrAddressTestID[1 : len(addressOrAddressTestID)-1] + address := a.addressesByBDDAddressID[addressTestID] + return address.Email + } + return addressOrAddressTestID +} + +func (a *TestAccount) Password() string { + return a.password +} + +func (a *TestAccount) MailboxPassword() string { + return a.mailboxPassword +} + +func (a *TestAccount) IsTwoFAEnabled() bool { + return a.twoFAEnabled +} + +func (a *TestAccount) BridgePassword() string { + return BridgePassword +} diff --git a/test/accounts/accounts.go b/test/accounts/accounts.go new file mode 100644 index 00000000..d16b5cbb --- /dev/null +++ b/test/accounts/accounts.go @@ -0,0 +1,79 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge.Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package accounts + +import ( + "encoding/json" + "io/ioutil" + + "github.com/ProtonMail/proton-bridge/pkg/pmapi" + "github.com/pkg/errors" +) + +// BridgePassword is password to be used for IMAP or SMTP under tests. +const BridgePassword = "bridgepassword" + +type TestAccounts struct { + Users map[string]*pmapi.User // Key is user ID used in BDD. + Addresses map[string]map[string]*pmapi.Address // Key is real user ID, second key is address ID used in BDD. + Passwords map[string]string // Key is real user ID. + MailboxPasswords map[string]string // Key is real user ID. + TwoFAs map[string]bool // Key is real user ID. +} + +func Load(path string) (*TestAccounts, error) { + data, err := ioutil.ReadFile(path) //nolint[gosec] + if err != nil { + return nil, errors.Wrap(err, "failed to load JSON") + } + + var testAccounts TestAccounts + err = json.Unmarshal(data, &testAccounts) + if err != nil { + return nil, errors.Wrap(err, "failed to unmarshal JSON") + } + + return &testAccounts, nil +} + +func (a *TestAccounts) GetTestAccount(username string) *TestAccount { + return a.GetTestAccountWithAddress(username, "") +} + +func (a *TestAccounts) GetTestAccountWithAddress(username, addressID string) *TestAccount { + // Do lookup by full address and convert to name in tests. + // Used by getting real data to ensure correct address or address ID. + for key, user := range a.Users { + if user.Name == username { + username = key + break + } + } + user, ok := a.Users[username] + if !ok { + return nil + } + return newTestAccount( + user, + a.Addresses[user.Name], + addressID, + a.Passwords[user.Name], + a.MailboxPasswords[user.Name], + a.TwoFAs[user.Name], + ) +} diff --git a/test/accounts/fake.json b/test/accounts/fake.json new file mode 100644 index 00000000..f93bab39 --- /dev/null +++ b/test/accounts/fake.json @@ -0,0 +1,105 @@ +{ + "users": { + "user": { + "ID": "1", + "Name": "user" + }, + "user2fa": { + "ID": "2", + "Name": "user2fa" + }, + "userAddressWithCapitalLetter": { + "ID": "3", + "Name": "userAddressWithCapitalLetter" + }, + "userMoreAddresses": { + "ID": "4", + "Name": "userMoreAddresses" + }, + "userDisabledPrimaryAddress": { + "ID": "5", + "Name": "userDisabledPrimaryAddress" + } + }, + "addresses": { + "user": { + "userAddress": { + "ID": "userAddress", + "Email": "user@pm.me", + "Order": 1, + "Receive": 1 + } + }, + "user2fa": { + "user2faAddress": { + "ID": "user2faAddress", + "Email": "user@pm.me", + "Order": 1, + "Receive": 1 + } + }, + "userAddressWithCapitalLetter": { + "userAddressWithCapitalLetterAddress": { + "ID": "userAddressWithCapitalLetterAddress", + "Email": "uSeR@pm.me", + "Order": 1, + "Receive": 1 + } + }, + "userMoreAddresses": { + "primary": { + "ID": "primary", + "Email": "primaryaddress@pm.me", + "Order": 1, + "Receive": 1 + }, + "secondary": { + "ID": "secondary", + "Email": "secondaryaddress@pm.me", + "Order": 2, + "Receive": 1 + }, + "disabled": { + "ID": "disabled", + "Email": "disabledaddress@pm.me", + "Order": 3, + "Receive": 0 + } + }, + "userDisabledPrimaryAddress": { + "primary": { + "ID": "primary", + "Email": "user@pm.me", + "Order": 1, + "Receive": 0 + }, + "secondary": { + "ID": "secondary", + "Email": "user@pm.me", + "Order": 2, + "Receive": 1 + } + } + }, + "passwords": { + "user": "password", + "user2fa": "password", + "userAddressWithCapitalLetter": "password", + "userMoreAddresses": "password", + "userDisabledPrimaryAddress": "password" + }, + "mailboxPasswords": { + "user": "password", + "user2fa": "password", + "userAddressWithCapitalLetter": "password", + "userMoreAddresses": "password", + "userDisabledPrimaryAddress": "password" + }, + "twoFAs": { + "user": false, + "user2fa": true, + "userAddressWithCapitalLetter": false, + "userMoreAddresses": false, + "userDisabledPrimaryAddress": false + } +} \ No newline at end of file diff --git a/test/api_checks_test.go b/test/api_checks_test.go new file mode 100644 index 00000000..089f7767 --- /dev/null +++ b/test/api_checks_test.go @@ -0,0 +1,79 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge.Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package tests + +import ( + "fmt" + "regexp" + "strings" + + "github.com/cucumber/godog" + "github.com/cucumber/godog/gherkin" +) + +func APIChecksFeatureContext(s *godog.Suite) { + s.Step(`^API endpoint "([^"]*)" is called with:$`, apiIsCalledWith) + s.Step(`^message is sent with API call:$`, messageIsSentWithAPICall) +} + +func apiIsCalledWith(endpoint string, data *gherkin.DocString) error { + split := strings.Split(endpoint, " ") + method := split[0] + path := split[1] + request := []byte(data.Content) + if !ctx.GetPMAPIController().WasCalled(method, path, request) { + return fmt.Errorf("%s was not called with %s", endpoint, request) + } + return nil +} + +func messageIsSentWithAPICall(data *gherkin.DocString) error { + endpoint := "POST /messages" + if err := apiIsCalledWith(endpoint, data); err != nil { + return err + } + for _, request := range ctx.GetPMAPIController().GetCalls("POST", "/messages") { + if !checkAllRequiredFieldsForSendingMessage(request) { + return fmt.Errorf("%s was not called with all required fields: %s", endpoint, request) + } + } + + return nil +} + +func checkAllRequiredFieldsForSendingMessage(request []byte) bool { + if matches := regexp.MustCompile(`"Subject":`).Match(request); !matches { + return false + } + if matches := regexp.MustCompile(`"ToList":`).Match(request); !matches { + return false + } + if matches := regexp.MustCompile(`"CCList":`).Match(request); !matches { + return false + } + if matches := regexp.MustCompile(`"BCCList":`).Match(request); !matches { + return false + } + if matches := regexp.MustCompile(`"AddressID":`).Match(request); !matches { + return false + } + if matches := regexp.MustCompile(`"Body":`).Match(request); !matches { + return false + } + return true +} diff --git a/test/bdd_test.go b/test/bdd_test.go new file mode 100644 index 00000000..17104b51 --- /dev/null +++ b/test/bdd_test.go @@ -0,0 +1,66 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge.Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package tests + +import ( + "github.com/ProtonMail/proton-bridge/test/context" + "github.com/cucumber/godog" +) + +func FeatureContext(s *godog.Suite) { + s.BeforeScenario(beforeScenario) + s.AfterScenario(afterScenario) + + APIChecksFeatureContext(s) + + BridgeActionsFeatureContext(s) + BridgeChecksFeatureContext(s) + BridgeSetupFeatureContext(s) + + IMAPActionsAuthFeatureContext(s) + IMAPActionsMailboxFeatureContext(s) + IMAPActionsMessagesFeatureContext(s) + IMAPChecksFeatureContext(s) + IMAPSetupFeatureContext(s) + + SMTPActionsAuthFeatureContext(s) + SMTPChecksFeatureContext(s) + SMTPSetupFeatureContext(s) + + StoreActionsFeatureContext(s) + StoreChecksFeatureContext(s) + StoreSetupFeatureContext(s) +} + +var ctx *context.TestContext //nolint[gochecknoglobals] + +func beforeScenario(scenario interface{}) { + ctx = context.New() +} + +func afterScenario(scenario interface{}, err error) { + if err != nil { + for _, user := range ctx.GetBridge().GetUsers() { + user.GetStore().TestDumpDB(ctx.GetTestingT()) + } + } + ctx.Cleanup() + if err != nil { + ctx.GetPMAPIController().PrintCalls() + } +} diff --git a/test/benchmarks/bench_results/human-table.py b/test/benchmarks/bench_results/human-table.py new file mode 100644 index 00000000..34ff905b --- /dev/null +++ b/test/benchmarks/bench_results/human-table.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python + +import glob +import pandas as pd +import re + + +def print_header(report_file): + print('\n======== %s ========' % + (report_file.replace("./bench-", "").replace(".log", ""))) + + +rx_line = { + 'exists': re.compile(r'.*Res[A-Za-z]?: [*] (?P\d+) EXISTS.*\n'), + 'bench': re.compile(r'Benchmark(?P[^ \t]+)[ \t]+(?P\d+)[ \t]+(?P\d+) ns/op.*\n'), + # 'total' : re.compile(r'ok[ \t]+(?P[^ \t]+)[ \t]+(?P