Compare commits

...

21 Commits

Author SHA1 Message Date
43cbedafb8 chore: Colorado Bridge 3.13.0 changelog. 2024-08-30 15:35:30 +02:00
ac9ab8ab32 fix(BRIDGE-81): update KB suggestions. 2024-08-30 09:50:41 +02:00
f04350c046 feat(BRIDGE-37): Remote notification support 2024-08-29 13:31:37 +02:00
ed1b65731a feat(BRIDGE-81): kb article suggestion updates + more weight for long keywords. 2024-08-27 16:16:23 +02:00
d12928b31c feat(BRIDGE-122): Observability service implementation 2024-08-27 15:21:41 +02:00
1ea06a95b7 fix(BRIDGE-138): remove deprecated doc. 2024-08-27 08:49:15 +02:00
e290cd308b feat(BRIDGE-119): added support for Feature Flags 2024-08-21 14:54:27 +02:00
3d53bf7477 feat(BRIDGE-116): add command-line switches to enable/disable keychain check on macOS. 2024-08-09 09:53:46 +02:00
84c0b907d7 chore: Bastei Bridge 3.12.0 changelog.
(cherry picked from commit ed5adb18fb)
2024-08-09 08:32:13 +02:00
b30455b641 chore: Bastei Bridge 3.12.0 changelog.
(cherry picked from commit 48a75b0dd7)
2024-08-09 08:32:13 +02:00
db9902e70b feat(BRIDGE-97): added repair button telemetry
(cherry picked from commit 85a91c5572)
2024-08-09 08:26:56 +02:00
f1f63c1d03 feat(BRIDGE-79): update to the KB suggestion list.
(cherry picked from commit 56d4bfbb71)
2024-08-09 08:23:19 +02:00
81a3c2aba8 feat(BRIDGE-88): added context menu for quick actions on input labels: cut, copy, paste 2024-08-07 10:19:40 +00:00
bbfc9beb04 chore: update GPA. 2024-07-30 11:02:17 +02:00
c4dba09ee6 ci: updated devsecops kit to latest. 2024-07-26 08:54:37 +02:00
a5435eb1da ci: BRIDGE-113 adding kits CI/CD component 2024-07-24 12:45:47 +02:00
54c56efdfa test(GODT-3205): Report results from nightly integration tests to Testmo 2024-07-16 09:04:27 +00:00
fc64dbec59 chore: golangci-lint update. 2024-07-11 16:29:59 +02:00
5d3f084a2b test(BRIDGE-109): Update KB articles failing integration tests
- Change KB article suggestions based on description
2024-07-11 09:28:38 +00:00
606d6c0e3e chore: temporarily silence GO-2024-2963 in govulncheck 2024-07-11 07:34:51 +02:00
9fbb6b4ca5 fix(BRIDGE-67): added detection for username changes on macOS & automatic reconfiguration 2024-06-20 12:32:42 +00:00
118 changed files with 4423 additions and 2151 deletions

View File

@ -34,6 +34,7 @@ before_script:
stages: stages:
- analyse - analyse
- test - test
- report
- build - build
include: include:
@ -41,12 +42,9 @@ include:
- local: ci/rules.yml - local: ci/rules.yml
- local: ci/env.yml - local: ci/env.yml
- local: ci/test.yml - local: ci/test.yml
- local: ci/report.yml
- local: ci/build.yml - local: ci/build.yml
- component: gitlab.protontech.ch/proton/devops/cicd-components/devsecops/gitleaks/scan-repository@~latest - component: gitlab.protontech.ch/proton/devops/cicd-components/kits/devsecops/go@~latest
inputs:
stage: analyse
cli-args: "--baseline-path $GITLEAKS_BASELINE"
- component: gitlab.protontech.ch/proton/devops/cicd-components/devsecops/grype/scan-code@~latest
inputs: inputs:
stage: analyse stage: analyse

View File

@ -2,11 +2,12 @@
run: run:
timeout: 10m timeout: 10m
skip-dirs: skip-dirs:
- pkg/mime
- extern
issues: issues:
exclude-use-default: false exclude-use-default: false
exclude-dirs:
- pkg/mime
- extern
exclude: exclude:
- Using the variable on range scope `tt` in function literal - Using the variable on range scope `tt` in function literal
# For now we are missing a lot of comments. # For now we are missing a lot of comments.

View File

@ -3,6 +3,44 @@
Changelog [format](http://keepachangelog.com/en/1.0.0/) Changelog [format](http://keepachangelog.com/en/1.0.0/)
## Colorado Bridge 3.13.0
### Added
* BRIDGE-37: added message broadcasting functionality.
* BRIDGE-122: added observability service.
* BRIDGE-119: added support for Feature Flags.
* BRIDGE-116: added command-line switches to enable/disable keychain check on macOS.
* BRIDGE-88: added context menu for quick actions on input labels: cut, copy, paste.
### Changed
* BRIDGE-81: KB article suggestion updates + more weight for long keywords.
### Fixed
* BRIDGE-67: Added detection for username changes on macOS & automatic reconfiguration.
* BRIDGE-138: Remove deprecated doc.
## Bastei Bridge 3.12.0
### Added
* BRIDGE-75: Bridge repair button.
* BRIDGE-79: Add New Outlook for Mac KB disclaimer.
### Changed
* BRIDGE-16: Bump version Go 1.21.9 Qt 6.4.3.
* BRIDGE-23: Update gluon to go 1.21.
* BRIDGE-22: Update gpa to go 1.21.
### Fixed
* BRIDGE-90: Disable repair button when bridge cannot connect to proton servers; bump GPA.
* BRIDGE-69: Explicitly handle semver panic for last bridge version from vault.
* BRIDGE-29: Bump gluon version.
* BRIDGE-49: Configure gitleaks baseline and grype config.
* BRIDGE-21: Missing panic handling.
* BRIDGE-17: Broken telemetry heartbeat test.
* BRIDGE-10: Bumped gluon version.
## Alcantara Bridge 3.11.1 ## Alcantara Bridge 3.11.1
### Fixed ### Fixed

View File

@ -12,7 +12,7 @@ ROOT_DIR:=$(realpath .)
.PHONY: build build-gui build-nogui build-launcher versioner hasher .PHONY: build build-gui build-nogui build-launcher versioner hasher
# Keep version hardcoded so app build works also without Git repository. # Keep version hardcoded so app build works also without Git repository.
BRIDGE_APP_VERSION?=3.11.1+git BRIDGE_APP_VERSION?=3.13.0+git
APP_VERSION:=${BRIDGE_APP_VERSION} APP_VERSION:=${BRIDGE_APP_VERSION}
APP_FULL_NAME:=Proton Mail Bridge APP_FULL_NAME:=Proton Mail Bridge
APP_VENDOR:=Proton AG APP_VENDOR:=Proton AG
@ -189,7 +189,7 @@ ${RESOURCE_FILE}: ./dist/info.rc ./dist/${SRC_ICO} .FORCE
## Dev dependencies ## Dev dependencies
.PHONY: install-devel-tools install-linter install-go-mod-outdated install-git-hooks .PHONY: install-devel-tools install-linter install-go-mod-outdated install-git-hooks
LINTVER:="v1.55.2" LINTVER:="v1.59.1"
LINTSRC:="https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh" LINTSRC:="https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh"
install-dev-dependencies: install-devel-tools install-linter install-go-mod-outdated install-dev-dependencies: install-devel-tools install-linter install-go-mod-outdated
@ -264,7 +264,8 @@ test-integration-race: gofiles
test-integration-nightly: gofiles test-integration-nightly: gofiles
mkdir -p coverage/integration mkdir -p coverage/integration
go test \ gotestsum \
--junitfile tests/result/feature-tests.xml -- \
-v -timeout=90m -p=1 -count=1 -tags=test_integration \ -v -timeout=90m -p=1 -count=1 -tags=test_integration \
${GOCOVERAGE} \ ${GOCOVERAGE} \
github.com/ProtonMail/proton-bridge/v3/tests \ github.com/ProtonMail/proton-bridge/v3/tests \

25
ci/report.yml Normal file
View File

@ -0,0 +1,25 @@
---
include:
- project: 'tpe/testmo-reporter'
ref: master
file: '/scenarios/testmo-script.yml'
testmo-upload:
stage: report
extends:
- .testmo-upload
- .rules-branch-manual-scheduled-and-test-branch-always
needs:
- test-integration-nightly
before_script: []
variables:
TESTMO_TOKEN: "$TESTMO_TOKEN"
TESTMO_URL: "https://proton.testmo.net"
PROJECT_ID: "9"
NAME: "Nightly integration tests"
MILESTONE: "Nightly integration tests"
SOURCE: "test-integration-nightly"
TAGS: "$CI_COMMIT_REF_SLUG"
RESULT_FOLDER: "tests/result/*.xml"

View File

@ -108,6 +108,7 @@ test-integration-nightly:
artifacts: artifacts:
when: always when: always
paths: paths:
- tests/result/feature-tests.xml
- nightly-job.log - nightly-job.log
test-coverage: test-coverage:

View File

@ -1,135 +0,0 @@
# 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[Proton Mail 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).

View File

@ -1,114 +0,0 @@
# Communication
## First login and sync
When user logs in to the bridge for the first time, immediately 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 labels) 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.
Unfortunately 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<br/>by user
loop First sync
B ->> S: Fetch body and attachments
Note right of B: Build local database<br/>(e-mail UIDs)
end
Note right of C: Set up IMAP/SMTP<br/>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 attachments
Note right of B: Decrypt message<br/>and attachment
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
```

View File

@ -1,27 +0,0 @@
# 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`

View File

@ -1,12 +0,0 @@
# Encryption
Encryption is done in PMAPI, bridge utils and bridge itself. The best would be to keep encryption
in PMAPI and bridge utils (in package 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.

View File

@ -1,9 +0,0 @@
# Bridge Documentation
Documentation pages in order to read for a novice:
* [Bridge code](bridge.md)
* [Internal Bridge database](database.md)
* [Communication between Bridge, Client and Server](communication.md)
* [Encryption](encryption.md)

View File

@ -1,103 +0,0 @@
# Update mechanism of Bridge
There are multiple options how to change version of application:
* Automatic in-app update
* Manual in-app update
* Manual install
In-app update ends with restarting bridge into new version. Automatic in-app
update is downloading, verifying and installing the new version immediately
without user confirmation. For manual in-app update user needs to confirm first.
Update is done from special update file published on website.
The manual installation requires user to download, verify and install manually
using installer for given OS.
The bridge is installed and executed differently for given OS:
* Windows and Linux apps are using launcher mechanism:
* There is system protected installation path which is created on first
install. It contains bridge exe and launcher exe. When users starts
bridge the launcher is executed first. It will check update path compare
version with installed one. The newer version then is then executed.
* Update mechanism means to replace files in update folder which is located
in user space.
* macOS app does not use launcher
* No launcher, only one executable
* In-App update replaces the bridge files in installation path directly
```mermaid
flowchart LR
subgraph Frontend
U[User requests<br>version check]
ManIns((Notify user about<br>manual install<br>is needed))
R((Notify user<br>about restart))
ManUp((Notify user about<br>manual update))
NF((Notify user about<br>force update))
ManUp -->|Install| InstFront[Install]
InstFront -->|Ok| R
InstFront -->|Error| ManIns
U --> CheckFront[Check online]
CheckFront -->|Ok| IAFront{Is new version<br>and applicable?}
CheckFront -->|Error| ManIns
IAFront -->|No| Latest((Notify user<br>has latest version))
IAFront -->|Yes| CanInstall{Can update?}
CanInstall -->|No| ManIns
CanInstall -->|Yes| NotifOrInstall{Is automatic<br>update enabled?}
NotifOrInstall -->|Manual| ManUp
end
subgraph Backend
W[Wait for next check]
W --> Check[Check online]
Check --> NV{Has new<br>version?}
Check -->|Error| W
NV -->|No new version| W
IA{Is install<br>applicable?}
NV -->|New version<br>available| IA
IA -->|Local rollout<br>not enough| W
IA -->|Yes| AU{Is automatic\nupdate enabled?}
AU -->|Yes| CanUp{Can update?}
CanUp -->|No| ManIns
CanUp -->|Yes| Ins[Install]
Ins -->|Error| ManIns
Ins -->|Ok| R
AU -->|No| ManUp
ManUp -->|Ignore| W
F[Force update]
F --> NF
end
ManIns --> Web[Open web page]
NF --> Web
ManUp --> Web
R --> Re[Restart]
NF --> Q[Quit bridge]
NotifOrInstall -->|Automatic| W
```
The non-trivial is to combine the update with setting change:
* turn off/on automatic in-app updates
* change from stable to beta or back
_TODO fill flow chart details_
We are not support downgrade functionality. Only some circumstances can lead to
downgrading the app version.
_TODO fill flow chart details_

2
go.mod
View File

@ -9,7 +9,7 @@ require (
github.com/Masterminds/semver/v3 v3.2.0 github.com/Masterminds/semver/v3 v3.2.0
github.com/ProtonMail/gluon v0.17.1-0.20240514133734-79cdd0fec41c github.com/ProtonMail/gluon v0.17.1-0.20240514133734-79cdd0fec41c
github.com/ProtonMail/go-autostart v0.0.0-20210130080809-00ed301c8e9a github.com/ProtonMail/go-autostart v0.0.0-20210130080809-00ed301c8e9a
github.com/ProtonMail/go-proton-api v0.4.1-0.20240605113119-1a81ec7dc72d github.com/ProtonMail/go-proton-api v0.4.1-0.20240829112804-d663a2ef90c2
github.com/ProtonMail/gopenpgp/v2 v2.7.4-proton github.com/ProtonMail/gopenpgp/v2 v2.7.4-proton
github.com/PuerkitoBio/goquery v1.8.1 github.com/PuerkitoBio/goquery v1.8.1
github.com/abiosoft/ishell v2.0.0+incompatible github.com/abiosoft/ishell v2.0.0+incompatible

24
go.sum
View File

@ -27,8 +27,6 @@ github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAE
github.com/ProtonMail/bcrypt v0.0.0-20210511135022-227b4adcab57/go.mod h1:HecWFHognK8GfRDGnFQbW/LiV7A3MX3gZVs45vk5h8I= github.com/ProtonMail/bcrypt v0.0.0-20210511135022-227b4adcab57/go.mod h1:HecWFHognK8GfRDGnFQbW/LiV7A3MX3gZVs45vk5h8I=
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf h1:yc9daCCYUefEs69zUkSzubzjBbL+cmOXgnmt9Fyd9ug= github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf h1:yc9daCCYUefEs69zUkSzubzjBbL+cmOXgnmt9Fyd9ug=
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf/go.mod h1:o0ESU9p83twszAU8LBeJKFAAMX14tISa0yk4Oo5TOqo= github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf/go.mod h1:o0ESU9p83twszAU8LBeJKFAAMX14tISa0yk4Oo5TOqo=
github.com/ProtonMail/gluon v0.17.1-0.20240423123310-0266b0f75d41 h1:Lu2hKO4fcHeMcbZOon129iM1dAy0ERwZkJtuNQCLlOQ=
github.com/ProtonMail/gluon v0.17.1-0.20240423123310-0266b0f75d41/go.mod h1:0/c03TzZPNiSgY5UDJK1iRDkjlDPwWugxTT6et2qDu8=
github.com/ProtonMail/gluon v0.17.1-0.20240514133734-79cdd0fec41c h1:P3SvCACt13Zqdj0IRDB4bgwqI68+oMB2j0uVuPQyoTw= github.com/ProtonMail/gluon v0.17.1-0.20240514133734-79cdd0fec41c h1:P3SvCACt13Zqdj0IRDB4bgwqI68+oMB2j0uVuPQyoTw=
github.com/ProtonMail/gluon v0.17.1-0.20240514133734-79cdd0fec41c/go.mod h1:0/c03TzZPNiSgY5UDJK1iRDkjlDPwWugxTT6et2qDu8= github.com/ProtonMail/gluon v0.17.1-0.20240514133734-79cdd0fec41c/go.mod h1:0/c03TzZPNiSgY5UDJK1iRDkjlDPwWugxTT6et2qDu8=
github.com/ProtonMail/go-autostart v0.0.0-20210130080809-00ed301c8e9a h1:D+aZah+k14Gn6kmL7eKxoo/4Dr/lK3ChBcwce2+SQP4= github.com/ProtonMail/go-autostart v0.0.0-20210130080809-00ed301c8e9a h1:D+aZah+k14Gn6kmL7eKxoo/4Dr/lK3ChBcwce2+SQP4=
@ -40,10 +38,24 @@ github.com/ProtonMail/go-message v0.13.1-0.20230526094639-b62c999c85b7 h1:+j+Kd/
github.com/ProtonMail/go-message v0.13.1-0.20230526094639-b62c999c85b7/go.mod h1:NBAn21zgCJ/52WLDyed18YvYFm5tEoeDauubFqLokM4= github.com/ProtonMail/go-message v0.13.1-0.20230526094639-b62c999c85b7/go.mod h1:NBAn21zgCJ/52WLDyed18YvYFm5tEoeDauubFqLokM4=
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k= github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k=
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw= github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240423123404-a6163268401c h1:3U245DPGyL+LeAcJzFSg+E2lShXx+z/lBHM2v9P5mEg= github.com/ProtonMail/go-proton-api v0.4.1-0.20240612082117-0f92424eed80 h1:cP4+6RFn9vVgYnoDwxBU4EtIAZA+eM4rzOaSZNqZ1xg=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240423123404-a6163268401c/go.mod h1:3A0cpdo0BIenIPjTG6u8EbzJ8uuJy7rVvM/NaynjCKA= github.com/ProtonMail/go-proton-api v0.4.1-0.20240612082117-0f92424eed80/go.mod h1:3A0cpdo0BIenIPjTG6u8EbzJ8uuJy7rVvM/NaynjCKA=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240605113119-1a81ec7dc72d h1:B9/ZLubPWIY4uvATviFoCUoLauq98C3Bbt4v0A2VEdU= github.com/ProtonMail/go-proton-api v0.4.1-0.20240808145610-88df257767f6 h1:nERxOYS4ndSgWEr834YYkb1j0bZK/dJAmhoyYB1MtNY=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240605113119-1a81ec7dc72d/go.mod h1:3A0cpdo0BIenIPjTG6u8EbzJ8uuJy7rVvM/NaynjCKA= github.com/ProtonMail/go-proton-api v0.4.1-0.20240808145610-88df257767f6/go.mod h1:3A0cpdo0BIenIPjTG6u8EbzJ8uuJy7rVvM/NaynjCKA=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240819131705-149e50199c5b h1:zifGh4LS5HwQIaVCccSe5/oJGTOjFeVObMRl3QJoJ3k=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240819131705-149e50199c5b/go.mod h1:3A0cpdo0BIenIPjTG6u8EbzJ8uuJy7rVvM/NaynjCKA=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240821081056-dd607af0f917 h1:Ma6PfXFDuw7rYYq28FXNW6ubhYquRUmBuLyZrjJWHUE=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240821081056-dd607af0f917/go.mod h1:3A0cpdo0BIenIPjTG6u8EbzJ8uuJy7rVvM/NaynjCKA=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240822150235-7a6190889179 h1:6Xo0iRYa4GBgZ2HA+IR3KdqiML8Z10h2F9TYe+9n1+M=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240822150235-7a6190889179/go.mod h1:3A0cpdo0BIenIPjTG6u8EbzJ8uuJy7rVvM/NaynjCKA=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240827084449-71096377c391 h1:PW6bE+mhsfAx4+wDCCNjhFrCNiiuMjY6j7RwqRUdPKI=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240827084449-71096377c391/go.mod h1:3A0cpdo0BIenIPjTG6u8EbzJ8uuJy7rVvM/NaynjCKA=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240827122236-ca6bb6449bba h1:QtDxgIbgPqRQg7VT+nIUJlaOyNFAoGyg59oW3Hji/0A=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240827122236-ca6bb6449bba/go.mod h1:3A0cpdo0BIenIPjTG6u8EbzJ8uuJy7rVvM/NaynjCKA=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240827132526-849231fc34a1 h1:gATlMoj4raG32WyGGh8SpipoQeR2AlU7g+8NAMicTcw=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240827132526-849231fc34a1/go.mod h1:3A0cpdo0BIenIPjTG6u8EbzJ8uuJy7rVvM/NaynjCKA=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240829112804-d663a2ef90c2 h1:yx0iejqB5c21HIN5jn9IsbyzUns0dPUUaGfyUHF3TmQ=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240829112804-d663a2ef90c2/go.mod h1:3A0cpdo0BIenIPjTG6u8EbzJ8uuJy7rVvM/NaynjCKA=
github.com/ProtonMail/go-smtp v0.0.0-20231109081432-2b3d50599865 h1:EP1gnxLL5Z7xBSymE9nSTM27nRYINuvssAtDmG0suD8= github.com/ProtonMail/go-smtp v0.0.0-20231109081432-2b3d50599865 h1:EP1gnxLL5Z7xBSymE9nSTM27nRYINuvssAtDmG0suD8=
github.com/ProtonMail/go-smtp v0.0.0-20231109081432-2b3d50599865/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= github.com/ProtonMail/go-smtp v0.0.0-20231109081432-2b3d50599865/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/ProtonMail/go-srp v0.0.7 h1:Sos3Qk+th4tQR64vsxGIxYpN3rdnG9Wf9K4ZloC1JrI= github.com/ProtonMail/go-srp v0.0.7 h1:Sos3Qk+th4tQR64vsxGIxYpN3rdnG9Wf9K4ZloC1JrI=

View File

@ -79,11 +79,13 @@ const (
// Hidden flags. // Hidden flags.
const ( const (
flagLauncher = "launcher" flagLauncher = "launcher"
flagNoWindow = "no-window" flagNoWindow = "no-window"
flagParentPID = "parent-pid" flagParentPID = "parent-pid"
flagSoftwareRenderer = "software-renderer" flagSoftwareRenderer = "software-renderer"
FlagSessionID = "session-id" flagEnableKeychainTest = "enable-keychain-test"
flagDisableKeychainTest = "disable-keychain-test"
FlagSessionID = "session-id"
) )
const ( const (
@ -91,6 +93,20 @@ const (
appShortName = "bridge" appShortName = "bridge"
) )
var cliFlagEnableKeychainTest = &cli.BoolFlag{ //nolint:gochecknoglobals
Name: flagEnableKeychainTest,
Usage: "Enable the keychain test",
Hidden: true,
Value: false,
} //nolint:gochecknoglobals
var cliFlagDisableKeychainTest = &cli.BoolFlag{ //nolint:gochecknoglobals
Name: flagDisableKeychainTest,
Usage: "Disable the keychain test",
Hidden: true,
Value: false,
}
func New() *cli.App { func New() *cli.App {
app := cli.NewApp() app := cli.NewApp()
@ -168,6 +184,9 @@ func New() *cli.App {
Name: FlagSessionID, Name: FlagSessionID,
Hidden: true, Hidden: true,
}, },
// the two flags below were introduced by BRIDGE-116
cliFlagEnableKeychainTest,
cliFlagDisableKeychainTest,
} }
app.Action = run app.Action = run
@ -238,7 +257,8 @@ func run(c *cli.Context) error {
return withSingleInstance(settings, locations.GetLockFile(), version, func() error { return withSingleInstance(settings, locations.GetLockFile(), version, func() error {
// Look for available keychains // Look for available keychains
return WithKeychainList(crashHandler, func(keychains *keychain.List) error { skipKeychainTest := checkSkipKeychainTest(c, settings)
return WithKeychainList(crashHandler, skipKeychainTest, func(keychains *keychain.List) error {
// Unlock the encrypted vault. // Unlock the encrypted vault.
return WithVault(locations, keychains, crashHandler, func(v *vault.Vault, insecure, corrupt bool) error { return WithVault(locations, keychains, crashHandler, func(v *vault.Vault, insecure, corrupt bool) error {
if !v.Migrated() { if !v.Migrated() {
@ -502,11 +522,11 @@ func withCookieJar(vault *vault.Vault, fn func(http.CookieJar) error) error {
} }
// WithKeychainList init the list of usable keychains. // WithKeychainList init the list of usable keychains.
func WithKeychainList(panicHandler async.PanicHandler, fn func(*keychain.List) error) error { func WithKeychainList(panicHandler async.PanicHandler, skipKeychainTest bool, fn func(*keychain.List) error) error {
logrus.Debug("Creating keychain list") logrus.Debug("Creating keychain list")
defer logrus.Debug("Keychain list stop") defer logrus.Debug("Keychain list stop")
defer async.HandlePanic(panicHandler) defer async.HandlePanic(panicHandler)
return fn(keychain.NewList()) return fn(keychain.NewList(skipKeychainTest))
} }
func setDeviceCookies(jar *cookies.Jar) error { func setDeviceCookies(jar *cookies.Jar) error {
@ -526,3 +546,35 @@ func setDeviceCookies(jar *cookies.Jar) error {
return nil return nil
} }
func checkSkipKeychainTest(c *cli.Context, settingsDir string) bool {
if runtime.GOOS != "darwin" {
return false
}
enable := c.Bool(flagEnableKeychainTest)
disable := c.Bool(flagDisableKeychainTest)
skip, err := vault.GetShouldSkipKeychainTest(settingsDir)
if err != nil {
logrus.WithError(err).Error("Could not load keychain settings.")
}
if (!enable) && (!disable) {
return skip
}
// if both switches are passed, 'enable' has priority
if disable {
skip = true
}
if enable {
skip = false
}
if err := vault.SetShouldSkipKeychainTest(settingsDir, skip); err != nil {
logrus.WithError(err).Error("Could not save keychain settings.")
}
return skip
}

65
internal/app/app_test.go Normal file
View File

@ -0,0 +1,65 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package app
import (
"runtime"
"testing"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v2"
)
func TestCheckSkipKeychainTest(t *testing.T) {
var expectedResult bool
dir := t.TempDir()
app := cli.App{
Flags: []cli.Flag{
cliFlagEnableKeychainTest,
cliFlagDisableKeychainTest,
},
Action: func(c *cli.Context) error {
require.Equal(t, expectedResult, checkSkipKeychainTest(c, dir))
return nil
},
}
noArgs := []string{"appName"}
enableArgs := []string{"appName", "-" + flagEnableKeychainTest}
disableArgs := []string{"appName", "-" + flagDisableKeychainTest}
bothArgs := []string{"appName", "-" + flagDisableKeychainTest, "-" + flagEnableKeychainTest}
const trueOnlyOnMac = runtime.GOOS == "darwin"
expectedResult = false
require.NoError(t, app.Run(noArgs))
expectedResult = trueOnlyOnMac
require.NoError(t, app.Run(disableArgs))
require.NoError(t, app.Run(noArgs))
expectedResult = false
require.NoError(t, app.Run(enableArgs))
require.NoError(t, app.Run(noArgs))
expectedResult = trueOnlyOnMac
require.NoError(t, app.Run(disableArgs))
expectedResult = false
require.NoError(t, app.Run(bothArgs))
}

View File

@ -24,6 +24,10 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"os"
"regexp"
"runtime"
"strings"
"sync" "sync"
"time" "time"
@ -41,8 +45,11 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/safe" "github.com/ProtonMail/proton-bridge/v3/internal/safe"
"github.com/ProtonMail/proton-bridge/v3/internal/sentry" "github.com/ProtonMail/proton-bridge/v3/internal/sentry"
"github.com/ProtonMail/proton-bridge/v3/internal/services/imapsmtpserver" "github.com/ProtonMail/proton-bridge/v3/internal/services/imapsmtpserver"
"github.com/ProtonMail/proton-bridge/v3/internal/services/notifications"
"github.com/ProtonMail/proton-bridge/v3/internal/services/observability"
"github.com/ProtonMail/proton-bridge/v3/internal/services/syncservice" "github.com/ProtonMail/proton-bridge/v3/internal/services/syncservice"
"github.com/ProtonMail/proton-bridge/v3/internal/telemetry" "github.com/ProtonMail/proton-bridge/v3/internal/telemetry"
"github.com/ProtonMail/proton-bridge/v3/internal/unleash"
"github.com/ProtonMail/proton-bridge/v3/internal/user" "github.com/ProtonMail/proton-bridge/v3/internal/user"
"github.com/ProtonMail/proton-bridge/v3/internal/vault" "github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/pkg/keychain" "github.com/ProtonMail/proton-bridge/v3/pkg/keychain"
@ -51,6 +58,8 @@ import (
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
var usernameChangeRegex = regexp.MustCompile(`^/Users/([^/]+)/`)
type Bridge struct { type Bridge struct {
// vault holds bridge-specific data, such as preferences and known users (authorized or not). // vault holds bridge-specific data, such as preferences and known users (authorized or not).
vault *vault.Vault vault *vault.Vault
@ -130,6 +139,15 @@ type Bridge struct {
serverManager *imapsmtpserver.Service serverManager *imapsmtpserver.Service
syncService *syncservice.Service syncService *syncservice.Service
// unleashService is responsible for polling the feature flags and caching
unleashService *unleash.Service
// observabilityService is responsible for handling calls to the observability system
observabilityService *observability.Service
// notificationStore is used for notification deduplication
notificationStore *notifications.Store
} }
var logPkg = logrus.WithField("pkg", "bridge") //nolint:gochecknoglobals var logPkg = logrus.WithField("pkg", "bridge") //nolint:gochecknoglobals
@ -247,6 +265,8 @@ func newBridge(
return nil, fmt.Errorf("failed to create focus service: %w", err) return nil, fmt.Errorf("failed to create focus service: %w", err)
} }
unleashService := unleash.NewBridgeService(ctx, api, locator, panicHandler)
bridge := &Bridge{ bridge := &Bridge{
vault: vault, vault: vault,
@ -287,6 +307,12 @@ func newBridge(
tasks: tasks, tasks: tasks,
syncService: syncservice.NewService(reporter, panicHandler), syncService: syncservice.NewService(reporter, panicHandler),
unleashService: unleashService,
observabilityService: observability.NewService(ctx, panicHandler),
notificationStore: notifications.NewStore(locator.ProvideNotificationsCachePath),
} }
bridge.serverManager = imapsmtpserver.NewService(context.Background(), bridge.serverManager = imapsmtpserver.NewService(context.Background(),
@ -299,6 +325,9 @@ func newBridge(
&bridgeIMAPSMTPTelemetry{b: bridge}, &bridgeIMAPSMTPTelemetry{b: bridge},
) )
// Check whether username has changed and correct (macOS only)
bridge.verifyUsernameChange()
if err := bridge.serverManager.Init(context.Background(), bridge.tasks, &bridgeEventSubscription{b: bridge}); err != nil { if err := bridge.serverManager.Init(context.Background(), bridge.tasks, &bridgeEventSubscription{b: bridge}); err != nil {
return nil, err return nil, err
} }
@ -311,6 +340,10 @@ func newBridge(
bridge.syncService.Run() bridge.syncService.Run()
bridge.unleashService.Run()
bridge.observabilityService.Run()
return bridge, nil return bridge, nil
} }
@ -438,6 +471,9 @@ func (bridge *Bridge) GetErrors() []error {
func (bridge *Bridge) Close(ctx context.Context) { func (bridge *Bridge) Close(ctx context.Context) {
logPkg.Info("Closing bridge") logPkg.Info("Closing bridge")
// Stop observability service
bridge.observabilityService.Stop()
// Stop heart beat before closing users. // Stop heart beat before closing users.
bridge.heartbeat.stop() bridge.heartbeat.stop()
@ -461,6 +497,9 @@ func (bridge *Bridge) Close(ctx context.Context) {
// Close the focus service. // Close the focus service.
bridge.focusService.Close() bridge.focusService.Close()
// Close the unleash service.
bridge.unleashService.Close()
// Close the watchers. // Close the watchers.
bridge.watchersLock.Lock() bridge.watchersLock.Lock()
defer bridge.watchersLock.Unlock() defer bridge.watchersLock.Unlock()
@ -541,9 +580,9 @@ func (bridge *Bridge) onStatusDown(ctx context.Context) {
func (bridge *Bridge) Repair() { func (bridge *Bridge) Repair() {
var wg sync.WaitGroup var wg sync.WaitGroup
userIDS := bridge.GetUserIDs() userIDs := bridge.GetUserIDs()
for _, userID := range userIDS { for _, userID := range userIDs {
logPkg.Info("Initiating repair for userID:", userID) logPkg.Info("Initiating repair for userID:", userID)
userInfo, err := bridge.GetUserInfo(userID) userInfo, err := bridge.GetUserInfo(userID)
@ -573,7 +612,7 @@ func (bridge *Bridge) Repair() {
wg.Add(1) wg.Add(1)
go func(userID string) { go func(userID string) {
defer wg.Done() defer wg.Done()
if err = bridgeUser.ResyncIMAP(); err != nil { if err = bridgeUser.TriggerRepair(); err != nil {
logPkg.WithError(err).Error("Failed re-syncing IMAP for userID", userID) logPkg.WithError(err).Error("Failed re-syncing IMAP for userID", userID)
} }
}(userID) }(userID)
@ -605,3 +644,71 @@ func min(a, b time.Duration) time.Duration {
func (bridge *Bridge) HasAPIConnection() bool { func (bridge *Bridge) HasAPIConnection() bool {
return bridge.api.GetStatus() == proton.StatusUp return bridge.api.GetStatus() == proton.StatusUp
} }
// verifyUsernameChange - works only on macOS
// it attempts to check whether a username change has taken place by comparing the gluon DB path (which is static and provided by bridge)
// to the gluon Cache path - which can be modified by the user and is stored in the vault;
// if a username discrepancy is detected, and the cache folder does not exist with the "old" username
// then we verify whether the gluon cache exists using the "new" username (provided by the DB path in this case)
// if so we modify the cache directory in the user vault.
func (bridge *Bridge) verifyUsernameChange() {
if runtime.GOOS != "darwin" {
return
}
gluonDBPath, err := bridge.GetGluonDataDir()
if err != nil {
logPkg.WithError(err).Error("Failed to get gluon db path")
return
}
gluonCachePath := bridge.GetGluonCacheDir()
// If the cache folder exists even on another user account or is in `/Users/Shared` we would still be able to access it
// though it depends on the permissions; this is an edge-case.
if _, err := os.Stat(gluonCachePath); err == nil {
return
}
newCacheDir := GetUpdatedCachePath(gluonDBPath, gluonCachePath)
if newCacheDir == "" {
return
}
if _, err := os.Stat(newCacheDir); err == nil {
logPkg.Info("Username change detected. Trying to restore gluon cache directory")
if err = bridge.vault.SetGluonDir(newCacheDir); err != nil {
logPkg.WithError(err).Error("Failed to restore gluon cache directory")
return
}
logPkg.Info("Successfully restored gluon cache directory")
}
}
func GetUpdatedCachePath(gluonDBPath, gluonCachePath string) string {
// If gluon cache is moved to an external drive; regex find will fail; as is expected
cachePathMatches := usernameChangeRegex.FindStringSubmatch(gluonCachePath)
if cachePathMatches == nil || len(cachePathMatches) < 2 {
return ""
}
cacheUsername := cachePathMatches[1]
dbPathMatches := usernameChangeRegex.FindStringSubmatch(gluonDBPath)
if dbPathMatches == nil || len(dbPathMatches) < 2 {
return ""
}
dbUsername := dbPathMatches[1]
if cacheUsername == dbUsername {
return ""
}
return strings.Replace(gluonCachePath, "/Users/"+cacheUsername+"/", "/Users/"+dbUsername+"/", 1)
}
func (bridge *Bridge) GetFeatureFlagValue(key string) bool {
return bridge.unleashService.GetFlagValue(key)
}
func (bridge *Bridge) PushObservabilityMetric(metric proton.ObservabilityMetric) {
bridge.observabilityService.AddMetric(metric)
}

View File

@ -76,7 +76,7 @@ func init() {
func TestBridge_ConnStatus(t *testing.T) { func TestBridge_ConnStatus(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// Get a stream of connection status events. // Get a stream of connection status events.
eventCh, done := bridge.GetEvents(events.ConnStatusUp{}, events.ConnStatusDown{}) eventCh, done := bridge.GetEvents(events.ConnStatusUp{}, events.ConnStatusDown{})
defer done() defer done()
@ -125,7 +125,7 @@ func TestBridge_TLSIssue(t *testing.T) {
func TestBridge_Focus(t *testing.T) { func TestBridge_Focus(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// Get a stream of TLS issue events. // Get a stream of TLS issue events.
raiseCh, done := bridge.GetEvents(events.Raise{}) raiseCh, done := bridge.GetEvents(events.Raise{})
defer done() defer done()
@ -156,7 +156,7 @@ func TestBridge_UserAgent(t *testing.T) {
calls = append(calls, call) calls = append(calls, call)
}) })
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// Set the platform to something other than the default. // Set the platform to something other than the default.
bridge.SetCurrentPlatform("platform") bridge.SetCurrentPlatform("platform")
@ -183,7 +183,7 @@ func TestBridge_UserAgent_Persistence(t *testing.T) {
_, _, err := s.CreateUser(otherUser, otherPassword) _, _, err := s.CreateUser(otherUser, otherPassword)
require.NoError(t, err) require.NoError(t, err)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, _ *bridge.Mocks) {
currentUserAgent := b.GetCurrentUserAgent() currentUserAgent := b.GetCurrentUserAgent()
require.Contains(t, currentUserAgent, useragent.DefaultUserAgent) require.Contains(t, currentUserAgent, useragent.DefaultUserAgent)
@ -211,7 +211,7 @@ func TestBridge_UserAgent_Persistence(t *testing.T) {
require.Contains(t, b.GetCurrentUserAgent(), "MyFancyClient/0.1.2") require.Contains(t, b.GetCurrentUserAgent(), "MyFancyClient/0.1.2")
}) })
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
currentUserAgent := bridge.GetCurrentUserAgent() currentUserAgent := bridge.GetCurrentUserAgent()
require.Contains(t, currentUserAgent, "MyFancyClient/0.1.2") require.Contains(t, currentUserAgent, "MyFancyClient/0.1.2")
}) })
@ -225,7 +225,7 @@ func TestBridge_UserAgentFromUnknownClient(t *testing.T) {
_, _, err := s.CreateUser(otherUser, otherPassword) _, _, err := s.CreateUser(otherUser, otherPassword)
require.NoError(t, err) require.NoError(t, err)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, _ *bridge.Mocks) {
currentUserAgent := b.GetCurrentUserAgent() currentUserAgent := b.GetCurrentUserAgent()
require.Contains(t, currentUserAgent, useragent.DefaultUserAgent) require.Contains(t, currentUserAgent, useragent.DefaultUserAgent)
@ -255,7 +255,7 @@ func TestBridge_UserAgentFromSMTPClient(t *testing.T) {
_, _, err := s.CreateUser(otherUser, otherPassword) _, _, err := s.CreateUser(otherUser, otherPassword)
require.NoError(t, err) require.NoError(t, err)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, _ *bridge.Mocks) {
currentUserAgent := b.GetCurrentUserAgent() currentUserAgent := b.GetCurrentUserAgent()
require.Contains(t, currentUserAgent, useragent.DefaultUserAgent) require.Contains(t, currentUserAgent, useragent.DefaultUserAgent)
@ -305,7 +305,7 @@ func TestBridge_UserAgentFromIMAPID(t *testing.T) {
_, _, err := s.CreateUser(otherUser, otherPassword) _, _, err := s.CreateUser(otherUser, otherPassword)
require.NoError(t, err) require.NoError(t, err)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, _ *bridge.Mocks) {
require.NoError(t, getErr(b.LoginFull(ctx, otherUser, otherPassword, nil, nil))) require.NoError(t, getErr(b.LoginFull(ctx, otherUser, otherPassword, nil, nil)))
imapClient, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, b.GetIMAPPort())) imapClient, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, b.GetIMAPPort()))
@ -365,13 +365,13 @@ func TestBridge_Cookies(t *testing.T) {
}) })
// Start bridge and add a user so that API assigns us a session ID via cookie. // Start bridge and add a user so that API assigns us a session ID via cookie.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
_, err := bridge.LoginFull(context.Background(), username, password, nil, nil) _, err := bridge.LoginFull(context.Background(), username, password, nil, nil)
require.NoError(t, err) require.NoError(t, err)
}) })
// Start bridge again and check that it uses the same session ID. // Start bridge again and check that it uses the same session ID.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(_ *bridge.Bridge, _ *bridge.Mocks) {
// ... // ...
}) })
@ -484,7 +484,7 @@ func TestBridge_ManualUpdate(t *testing.T) {
func TestBridge_ForceUpdate(t *testing.T) { func TestBridge_ForceUpdate(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// Get a stream of update events. // Get a stream of update events.
updateCh, done := bridge.GetEvents(events.UpdateForced{}) updateCh, done := bridge.GetEvents(events.UpdateForced{})
defer done() defer done()
@ -507,7 +507,7 @@ func TestBridge_BadVaultKey(t *testing.T) {
var userID string var userID string
// Login a user. // Login a user.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
newUserID, err := bridge.LoginFull(context.Background(), username, password, nil, nil) newUserID, err := bridge.LoginFull(context.Background(), username, password, nil, nil)
require.NoError(t, err) require.NoError(t, err)
@ -515,17 +515,17 @@ func TestBridge_BadVaultKey(t *testing.T) {
}) })
// Start bridge with the correct vault key -- it should load the users correctly. // Start bridge with the correct vault key -- it should load the users correctly.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
require.ElementsMatch(t, []string{userID}, bridge.GetUserIDs()) require.ElementsMatch(t, []string{userID}, bridge.GetUserIDs())
}) })
// Start bridge with a bad vault key, the vault will be wiped and bridge will show no users. // Start bridge with a bad vault key, the vault will be wiped and bridge will show no users.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, []byte("bad"), func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, []byte("bad"), func(bridge *bridge.Bridge, _ *bridge.Mocks) {
require.Empty(t, bridge.GetUserIDs()) require.Empty(t, bridge.GetUserIDs())
}) })
// Start bridge with a nil vault key, the vault will be wiped and bridge will show no users. // Start bridge with a nil vault key, the vault will be wiped and bridge will show no users.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, nil, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, nil, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
require.Empty(t, bridge.GetUserIDs()) require.Empty(t, bridge.GetUserIDs())
}) })
}) })
@ -535,7 +535,7 @@ func TestBridge_MissingGluonStore(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) {
var gluonDir string var gluonDir string
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
_, err := bridge.LoginFull(context.Background(), username, password, nil, nil) _, err := bridge.LoginFull(context.Background(), username, password, nil, nil)
require.NoError(t, err) require.NoError(t, err)
@ -550,7 +550,7 @@ func TestBridge_MissingGluonStore(t *testing.T) {
require.NoError(t, os.RemoveAll(gluonDir)) require.NoError(t, os.RemoveAll(gluonDir))
// Bridge starts but can't find the gluon store dir; there should be no error. // Bridge starts but can't find the gluon store dir; there should be no error.
withBridgeWaitForServers(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridgeWaitForServers(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(_ *bridge.Bridge, _ *bridge.Mocks) {
// ... // ...
}) })
}) })
@ -560,7 +560,7 @@ func TestBridge_MissingGluonDatabase(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) {
var gluonDir string var gluonDir string
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
_, err := bridge.LoginFull(context.Background(), username, password, nil, nil) _, err := bridge.LoginFull(context.Background(), username, password, nil, nil)
require.NoError(t, err) require.NoError(t, err)
@ -573,7 +573,7 @@ func TestBridge_MissingGluonDatabase(t *testing.T) {
require.NoError(t, os.RemoveAll(gluonDir)) require.NoError(t, os.RemoveAll(gluonDir))
// Bridge starts but can't find the gluon database dir; there should be no error. // Bridge starts but can't find the gluon database dir; there should be no error.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(_ *bridge.Bridge, _ *bridge.Mocks) {
// ... // ...
}) })
}) })
@ -587,7 +587,7 @@ func TestBridge_AddressWithoutKeys(t *testing.T) {
) )
defer m.Close() defer m.Close()
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// Watch for sync finished event. // Watch for sync finished event.
syncCh, done := chToType[events.Event, events.SyncFinished](bridge.GetEvents(events.SyncFinished{})) syncCh, done := chToType[events.Event, events.SyncFinished](bridge.GetEvents(events.SyncFinished{}))
defer done() defer done()
@ -663,7 +663,7 @@ func TestBridge_FactoryReset(t *testing.T) {
func TestBridge_InitGluonDirectory(t *testing.T) { func TestBridge_InitGluonDirectory(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, _ *bridge.Mocks) {
configDir, err := b.GetGluonDataDir() configDir, err := b.GetGluonDataDir()
require.NoError(t, err) require.NoError(t, err)
@ -678,7 +678,7 @@ func TestBridge_InitGluonDirectory(t *testing.T) {
func TestBridge_LoginFailed(t *testing.T) { func TestBridge_LoginFailed(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
failCh, done := chToType[events.Event, events.IMAPLoginFailed](bridge.GetEvents(events.IMAPLoginFailed{})) failCh, done := chToType[events.Event, events.IMAPLoginFailed](bridge.GetEvents(events.IMAPLoginFailed{}))
defer done() defer done()
@ -706,7 +706,7 @@ func TestBridge_ChangeCacheDirectory(t *testing.T) {
createNumMessages(ctx, t, c, addrID, labelID, 10) createNumMessages(ctx, t, c, addrID, labelID, 10)
}) })
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, _ *bridge.Mocks) {
newCacheDir := t.TempDir() newCacheDir := t.TempDir()
currentCacheDir := b.GetGluonCacheDir() currentCacheDir := b.GetGluonCacheDir()
configDir, err := b.GetGluonDataDir() configDir, err := b.GetGluonDataDir()
@ -772,7 +772,7 @@ func TestBridge_ChangeAddressOrder(t *testing.T) {
createNumMessages(ctx, t, c, addrID, proton.InboxLabel, 10) createNumMessages(ctx, t, c, addrID, proton.InboxLabel, 10)
}) })
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, _ *bridge.Mocks) {
// Log the user in with its first address. // Log the user in with its first address.
syncCh, done := chToType[events.Event, events.SyncFinished](b.GetEvents(events.SyncFinished{})) syncCh, done := chToType[events.Event, events.SyncFinished](b.GetEvents(events.SyncFinished{}))
defer done() defer done()
@ -800,7 +800,7 @@ func TestBridge_ChangeAddressOrder(t *testing.T) {
require.NoError(t, c.OrderAddresses(ctx, proton.OrderAddressesReq{AddressIDs: []string{aliasID, addrID}})) require.NoError(t, c.OrderAddresses(ctx, proton.OrderAddressesReq{AddressIDs: []string{aliasID, addrID}}))
}) })
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, _ *bridge.Mocks) {
// We should still see 10 messages in the inbox. // We should still see 10 messages in the inbox.
info, err := b.GetUserInfo(userID) info, err := b.GetUserInfo(userID)
require.NoError(t, err) require.NoError(t, err)
@ -1077,3 +1077,57 @@ func waitForIMAPServerStopped(b *bridge.Bridge) *eventWaiter {
cancel: cancel, cancel: cancel,
} }
} }
func TestBridge_GetUpdatedCachePath(t *testing.T) {
type TestData struct {
gluonDBPath string
gluonCachePath string
shouldChange bool
}
dataArr := []TestData{
{
gluonDBPath: "/Users/test/",
gluonCachePath: "/Users/test/gluon",
shouldChange: false,
}, {
gluonDBPath: "/Users/test/",
gluonCachePath: "/Users/tester/gluon",
shouldChange: true,
}, {
gluonDBPath: "/Users/testing/",
gluonCachePath: "/Users/test/gluon",
shouldChange: true,
},
{
gluonDBPath: "/Users/testing/",
gluonCachePath: "/Users/test/gluon",
shouldChange: true,
},
{
gluonDBPath: "/Users/testing/",
gluonCachePath: "/Volumes/test/gluon",
shouldChange: false,
},
{
gluonDBPath: "/Volumes/test/",
gluonCachePath: "/Users/test/gluon",
shouldChange: false,
},
{
gluonDBPath: "/XXX/test/",
gluonCachePath: "/Users/test/gluon",
shouldChange: false,
},
{
gluonDBPath: "/XXX/test/",
gluonCachePath: "/YYY/test/gluon",
shouldChange: false,
},
}
for _, el := range dataArr {
newCachePath := bridge.GetUpdatedCachePath(el.gluonDBPath, el.gluonCachePath)
require.Equal(t, el.shouldChange, newCachePath != "" && newCachePath != el.gluonCachePath)
}
}

View File

@ -0,0 +1,97 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package bridge_test
import (
"testing"
"time"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/go-proton-api/server"
"github.com/ProtonMail/proton-bridge/v3/internal/bridge"
"github.com/ProtonMail/proton-bridge/v3/internal/services/observability"
"github.com/stretchr/testify/require"
"golang.org/x/net/context"
)
func TestBridge_Observability(t *testing.T) {
testMetric := proton.ObservabilityMetric{
Name: "test1",
Version: 1,
Timestamp: time.Now().Unix(),
Data: nil,
}
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) {
throttlePeriod := time.Millisecond * 500
observability.ModifyThrottlePeriod(throttlePeriod)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
require.NoError(t, getErr(bridge.LoginFull(ctx, username, password, nil, nil)))
bridge.PushObservabilityMetric(testMetric)
time.Sleep(time.Millisecond * 50) // Wait for the metric to be sent
require.Equal(t, 1, len(s.GetObservabilityStatistics().Metrics))
for i := 0; i < 10; i++ {
time.Sleep(time.Millisecond * 5) // Minor delay between each so our tests aren't flaky
bridge.PushObservabilityMetric(testMetric)
}
// We should still have only 1 metric sent as the throttleDuration has not passed
require.Equal(t, 1, len(s.GetObservabilityStatistics().Metrics))
// Wait for throttle duration to pass; we should have our remaining metrics posted
time.Sleep(throttlePeriod)
require.Equal(t, 11, len(s.GetObservabilityStatistics().Metrics))
// Wait for the throttle duration to reset; i.e. so we have enough time to send a request immediately
time.Sleep(throttlePeriod)
for i := 0; i < 10; i++ {
time.Sleep(time.Millisecond * 5)
bridge.PushObservabilityMetric(testMetric)
}
// We should only have one additional metric sent immediately
require.Equal(t, 12, len(s.GetObservabilityStatistics().Metrics))
// Wait for the others to be sent
time.Sleep(throttlePeriod)
require.Equal(t, 21, len(s.GetObservabilityStatistics().Metrics))
// Spam the endpoint a bit
for i := 0; i < 300; i++ {
if i < 200 {
time.Sleep(time.Millisecond * 10)
}
bridge.PushObservabilityMetric(testMetric)
}
// Ensure we've sent all metrics
time.Sleep(throttlePeriod)
observabilityStats := s.GetObservabilityStatistics()
require.Equal(t, 321, len(observabilityStats.Metrics))
// Verify that each request had a throttleDuration time difference between each request
for i := 0; i < len(observabilityStats.RequestTime)-1; i++ {
tOne := observabilityStats.RequestTime[i]
tTwo := observabilityStats.RequestTime[i+1]
require.True(t, tTwo.Sub(tOne).Abs() > throttlePeriod)
}
})
})
}

View File

@ -34,7 +34,7 @@ import (
func TestServerManager_ServersStartWithBridge(t *testing.T) { func TestServerManager_ServersStartWithBridge(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
imapClient, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, bridge.GetIMAPPort())) imapClient, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, bridge.GetIMAPPort()))
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, imapClient.Logout()) require.NoError(t, imapClient.Logout())
@ -48,7 +48,7 @@ func TestServerManager_ServersStartWithBridge(t *testing.T) {
func TestServerManager_ServersKeepsRunningfterUserLogsOut(t *testing.T) { func TestServerManager_ServersKeepsRunningfterUserLogsOut(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
userID, err := bridge.LoginFull(ctx, username, password, nil, nil) userID, err := bridge.LoginFull(ctx, username, password, nil, nil)
require.NoError(t, err) require.NoError(t, err)
@ -72,7 +72,7 @@ func TestServerManager_ServersDoNotStopWhenThereIsStillOneActiveUser(t *testing.
_, _, err := s.CreateUser(otherUser, otherPassword) _, _, err := s.CreateUser(otherUser, otherPassword)
require.NoError(t, err) require.NoError(t, err)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
_, err := bridge.LoginFull(ctx, username, password, nil, nil) _, err := bridge.LoginFull(ctx, username, password, nil, nil)
require.NoError(t, err) require.NoError(t, err)
@ -99,7 +99,7 @@ func TestServerManager_ServersDoNotStopWhenThereIsStillOneActiveUser(t *testing.
func TestServerManager_NetworkLossStopsServers(t *testing.T) { func TestServerManager_NetworkLossStopsServers(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
imapWaiter := waitForIMAPServerReady(bridge) imapWaiter := waitForIMAPServerReady(bridge)
defer imapWaiter.Done() defer imapWaiter.Done()

View File

@ -31,7 +31,7 @@ import (
func TestBridge_Settings_GluonDir(t *testing.T) { func TestBridge_Settings_GluonDir(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// Create a user. // Create a user.
_, err := bridge.LoginFull(context.Background(), username, password, nil, nil) _, err := bridge.LoginFull(context.Background(), username, password, nil, nil)
require.NoError(t, err) require.NoError(t, err)
@ -57,7 +57,7 @@ func TestBridge_Settings_GluonDirWithOnGoingEvents(t *testing.T) {
userID, addrID, err := s.CreateUser("imap", password) userID, addrID, err := s.CreateUser("imap", password)
require.NoError(t, err) require.NoError(t, err)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
syncCh, done := chToType[events.Event, events.SyncFinished](bridge.GetEvents(events.SyncFinished{})) syncCh, done := chToType[events.Event, events.SyncFinished](bridge.GetEvents(events.SyncFinished{}))
defer done() defer done()
@ -74,7 +74,7 @@ func TestBridge_Settings_GluonDirWithOnGoingEvents(t *testing.T) {
createNumMessages(ctx, t, c, addrID, labelID, 200) createNumMessages(ctx, t, c, addrID, labelID, 200)
}) })
withBridgeWaitForServers(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridgeWaitForServers(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// Create a new location for the Gluon data. // Create a new location for the Gluon data.
newGluonDir := t.TempDir() newGluonDir := t.TempDir()
@ -93,7 +93,7 @@ func TestBridge_Settings_GluonDirWithOnGoingEvents(t *testing.T) {
func TestBridge_Settings_IMAPPort(t *testing.T) { func TestBridge_Settings_IMAPPort(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
curPort := bridge.GetIMAPPort() curPort := bridge.GetIMAPPort()
// Set the port to 1144. // Set the port to 1144.
@ -110,7 +110,7 @@ func TestBridge_Settings_IMAPPort(t *testing.T) {
func TestBridge_Settings_IMAPSSL(t *testing.T) { func TestBridge_Settings_IMAPSSL(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// By default, IMAP SSL is disabled. // By default, IMAP SSL is disabled.
require.False(t, bridge.GetIMAPSSL()) require.False(t, bridge.GetIMAPSSL())
@ -125,7 +125,7 @@ func TestBridge_Settings_IMAPSSL(t *testing.T) {
func TestBridge_Settings_SMTPPort(t *testing.T) { func TestBridge_Settings_SMTPPort(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
curPort := bridge.GetSMTPPort() curPort := bridge.GetSMTPPort()
// Set the port to 1024. // Set the port to 1024.
@ -142,7 +142,7 @@ func TestBridge_Settings_SMTPPort(t *testing.T) {
func TestBridge_Settings_SMTPSSL(t *testing.T) { func TestBridge_Settings_SMTPSSL(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// By default, SMTP SSL is disabled. // By default, SMTP SSL is disabled.
require.False(t, bridge.GetSMTPSSL()) require.False(t, bridge.GetSMTPSSL())
@ -198,7 +198,7 @@ func TestBridge_Settings_Autostart(t *testing.T) {
func TestBridge_Settings_FirstStart(t *testing.T) { func TestBridge_Settings_FirstStart(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// By default, first start is true. // By default, first start is true.
require.True(t, bridge.GetFirstStart()) require.True(t, bridge.GetFirstStart())

View File

@ -232,7 +232,7 @@ func TestBridge_SyncWithOngoingEvents(t *testing.T) {
var total uint64 var total uint64
// The initial user should be fully synced. // The initial user should be fully synced.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
syncCh, done := chToType[events.Event, events.SyncFinished](bridge.GetEvents(events.SyncFinished{})) syncCh, done := chToType[events.Event, events.SyncFinished](bridge.GetEvents(events.SyncFinished{}))
defer done() defer done()
@ -246,7 +246,7 @@ func TestBridge_SyncWithOngoingEvents(t *testing.T) {
}) })
// Now let's remove the user and stop the network at 2/3 of the data. // Now let's remove the user and stop the network at 2/3 of the data.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
require.NoError(t, bridge.DeleteUser(ctx, userID)) require.NoError(t, bridge.DeleteUser(ctx, userID))
}) })
@ -254,7 +254,7 @@ func TestBridge_SyncWithOngoingEvents(t *testing.T) {
netCtl.SetReadLimit(2 * total / 3) netCtl.SetReadLimit(2 * total / 3)
// Login the user; its sync should fail. // Login the user; its sync should fail.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(b *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(b *bridge.Bridge, _ *bridge.Mocks) {
syncCh, done := chToType[events.Event, events.SyncFinished](b.GetEvents(events.SyncFinished{})) syncCh, done := chToType[events.Event, events.SyncFinished](b.GetEvents(events.SyncFinished{}))
defer done() defer done()
@ -592,7 +592,7 @@ func TestBridge_CorruptedVaultClearsPreviousIMAPSyncState(t *testing.T) {
createNumMessages(ctx, t, c, addrID, labelID, 100) createNumMessages(ctx, t, c, addrID, labelID, 100)
}) })
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
syncCh, done := chToType[events.Event, events.SyncFinished](bridge.GetEvents(events.SyncFinished{})) syncCh, done := chToType[events.Event, events.SyncFinished](bridge.GetEvents(events.SyncFinished{}))
defer done() defer done()
@ -625,7 +625,7 @@ func TestBridge_CorruptedVaultClearsPreviousIMAPSyncState(t *testing.T) {
require.NoError(t, os.WriteFile(filepath.Join(settingsPath, "vault.enc"), []byte("Trash!"), 0o600)) require.NoError(t, os.WriteFile(filepath.Join(settingsPath, "vault.enc"), []byte("Trash!"), 0o600))
// Bridge starts but can't find the gluon database dir; there should be no error. // Bridge starts but can't find the gluon database dir; there should be no error.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
_, err := bridge.LoginFull(context.Background(), "imap", password, nil, nil) _, err := bridge.LoginFull(context.Background(), "imap", password, nil, nil)
require.NoError(t, err) require.NoError(t, err)
}) })

View File

@ -33,6 +33,8 @@ type Locator interface {
GetDependencyLicensesLink() string GetDependencyLicensesLink() string
Clear(...string) error Clear(...string) error
ProvideIMAPSyncConfigPath() (string, error) ProvideIMAPSyncConfigPath() (string, error)
ProvideUnleashCachePath() (string, error)
ProvideNotificationsCachePath() (string, error)
} }
type ProxyController interface { type ProxyController interface {

View File

@ -0,0 +1,90 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package bridge_test
import (
"context"
"testing"
"time"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/go-proton-api/server"
"github.com/ProtonMail/proton-bridge/v3/internal/bridge"
"github.com/ProtonMail/proton-bridge/v3/internal/unleash"
"github.com/stretchr/testify/require"
)
func Test_UnleashService(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
unleash.ModifyPollPeriodAndJitter(500*time.Millisecond, 0)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(b *bridge.Bridge, _ *bridge.Mocks) {
// Initial startup assumes there is no cached feature flags.
require.Equal(t, b.GetFeatureFlagValue("test-1"), false)
require.Equal(t, b.GetFeatureFlagValue("test-2"), false)
require.Equal(t, b.GetFeatureFlagValue("test-3"), false)
s.PushFeatureFlag("test-1")
s.PushFeatureFlag("test-2")
// Wait for poll.
time.Sleep(time.Millisecond * 700)
require.Equal(t, b.GetFeatureFlagValue("test-1"), true)
require.Equal(t, b.GetFeatureFlagValue("test-2"), true)
require.Equal(t, b.GetFeatureFlagValue("test-3"), false)
s.PushFeatureFlag("test-3")
time.Sleep(time.Millisecond * 700) // Wait for poll again
require.Equal(t, b.GetFeatureFlagValue("test-1"), true)
require.Equal(t, b.GetFeatureFlagValue("test-2"), true)
require.Equal(t, b.GetFeatureFlagValue("test-3"), true)
})
// Wait for Bridge to close.
time.Sleep(time.Millisecond * 500)
// Second instance should have a feature flag cache file available. Therefore, all of the flags should evaluate to true on startup.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(b *bridge.Bridge, _ *bridge.Mocks) {
require.Equal(t, b.GetFeatureFlagValue("test-1"), true)
require.Equal(t, b.GetFeatureFlagValue("test-2"), true)
require.Equal(t, b.GetFeatureFlagValue("test-3"), true)
s.DeleteFeatureFlags()
require.Equal(t, b.GetFeatureFlagValue("test-1"), true)
require.Equal(t, b.GetFeatureFlagValue("test-2"), true)
require.Equal(t, b.GetFeatureFlagValue("test-3"), true)
time.Sleep(time.Millisecond * 700)
require.Equal(t, b.GetFeatureFlagValue("test-1"), false)
require.Equal(t, b.GetFeatureFlagValue("test-2"), false)
require.Equal(t, b.GetFeatureFlagValue("test-3"), false)
s.PushFeatureFlag("test-3")
require.Equal(t, b.GetFeatureFlagValue("test-1"), false)
require.Equal(t, b.GetFeatureFlagValue("test-2"), false)
require.Equal(t, b.GetFeatureFlagValue("test-3"), false)
time.Sleep(time.Millisecond * 700)
require.Equal(t, b.GetFeatureFlagValue("test-1"), false)
require.Equal(t, b.GetFeatureFlagValue("test-2"), false)
require.Equal(t, b.GetFeatureFlagValue("test-3"), true)
})
})
}

View File

@ -566,8 +566,12 @@ func (bridge *Bridge) addUserWithVault(
bridge.serverManager, bridge.serverManager,
&bridgeEventSubscription{b: bridge}, &bridgeEventSubscription{b: bridge},
bridge.syncService, bridge.syncService,
bridge.observabilityService,
syncSettingsPath, syncSettingsPath,
isNew, isNew,
bridge.notificationStore,
bridge.unleashService.GetFlagValue,
bridge.observabilityService.AddMetric,
) )
if err != nil { if err != nil {
return fmt.Errorf("failed to create user: %w", err) return fmt.Errorf("failed to create user: %w", err)

View File

@ -62,7 +62,7 @@ func TestBridge_User_RefreshEvent(t *testing.T) {
messageIDs = createNumMessages(ctx, t, c, addrID, labelID, 10) messageIDs = createNumMessages(ctx, t, c, addrID, labelID, 10)
}) })
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
userLoginAndSync(ctx, t, bridge, "user", password) userLoginAndSync(ctx, t, bridge, "user", password)
}) })
@ -73,7 +73,7 @@ func TestBridge_User_RefreshEvent(t *testing.T) {
require.NoError(t, s.RefreshUser(userID, proton.RefreshMail)) require.NoError(t, s.RefreshUser(userID, proton.RefreshMail))
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
syncCh, closeCh := chToType[events.Event, events.SyncFinished](bridge.GetEvents(events.SyncFinished{})) syncCh, closeCh := chToType[events.Event, events.SyncFinished](bridge.GetEvents(events.SyncFinished{}))
require.Equal(t, userID, (<-syncCh).UserID) require.Equal(t, userID, (<-syncCh).UserID)
@ -82,7 +82,7 @@ func TestBridge_User_RefreshEvent(t *testing.T) {
userContinueEventProcess(ctx, t, s, bridge) userContinueEventProcess(ctx, t, s, bridge)
}) })
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
withClient(ctx, t, s, "user", password, func(ctx context.Context, c *proton.Client) { withClient(ctx, t, s, "user", password, func(ctx context.Context, c *proton.Client) {
createNumMessages(ctx, t, c, addrID, labelID, 10) createNumMessages(ctx, t, c, addrID, labelID, 10)
}) })
@ -191,7 +191,7 @@ func TestBridge_User_BadMessage_NoBadEvent(t *testing.T) {
createNumMessages(ctx, t, c, addrID, proton.InboxLabel, 10) createNumMessages(ctx, t, c, addrID, proton.InboxLabel, 10)
}) })
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
userLoginAndSync(ctx, t, bridge, "user", password) userLoginAndSync(ctx, t, bridge, "user", password)
var messageIDs []string var messageIDs []string
@ -368,7 +368,7 @@ func TestBridge_User_Network_NoBadEvents(t *testing.T) {
_, addrID, err := s.CreateUser("user", password) _, addrID, err := s.CreateUser("user", password)
require.NoError(t, err) require.NoError(t, err)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
userLoginAndSync(ctx, t, bridge, "user", password) userLoginAndSync(ctx, t, bridge, "user", password)
// Create 10 more messages for the user, generating events. // Create 10 more messages for the user, generating events.
@ -454,7 +454,7 @@ func TestBridge_User_UpdateDraft(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Initially sync the user. // Initially sync the user.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
userLoginAndSync(ctx, t, bridge, "user", password) userLoginAndSync(ctx, t, bridge, "user", password)
}) })
@ -487,7 +487,7 @@ func TestBridge_User_UpdateDraft(t *testing.T) {
require.Empty(t, draft.ReplyTos) require.Empty(t, draft.ReplyTos)
// Process those events // Process those events
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
userContinueEventProcess(ctx, t, s, bridge) userContinueEventProcess(ctx, t, s, bridge)
}) })
@ -513,7 +513,7 @@ func TestBridge_User_UpdateDraftAndCreateOtherMessage(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Initially sync the user. // Initially sync the user.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
userLoginAndSync(ctx, t, bridge, "user", password) userLoginAndSync(ctx, t, bridge, "user", password)
}) })
@ -545,7 +545,7 @@ func TestBridge_User_UpdateDraftAndCreateOtherMessage(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Process those events // Process those events
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
userContinueEventProcess(ctx, t, s, bridge) userContinueEventProcess(ctx, t, s, bridge)
}) })
@ -573,7 +573,7 @@ func TestBridge_User_UpdateDraftAndCreateOtherMessage(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Process those events. // Process those events.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
userContinueEventProcess(ctx, t, s, bridge) userContinueEventProcess(ctx, t, s, bridge)
}) })
@ -581,7 +581,7 @@ func TestBridge_User_UpdateDraftAndCreateOtherMessage(t *testing.T) {
require.NoError(t, c.MarkMessagesUnread(ctx, res[0].MessageID)) require.NoError(t, c.MarkMessagesUnread(ctx, res[0].MessageID))
// Process those events. // Process those events.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
userContinueEventProcess(ctx, t, s, bridge) userContinueEventProcess(ctx, t, s, bridge)
}) })
}) })
@ -595,7 +595,7 @@ func TestBridge_User_SendDraftRemoveDraftFlag(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Initially sync the user. // Initially sync the user.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
userLoginAndSync(ctx, t, bridge, "user", password) userLoginAndSync(ctx, t, bridge, "user", password)
}) })
@ -628,7 +628,7 @@ func TestBridge_User_SendDraftRemoveDraftFlag(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Process those events // Process those events
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
userContinueEventProcess(ctx, t, s, bridge) userContinueEventProcess(ctx, t, s, bridge)
info, err := bridge.QueryUserInfo("user") info, err := bridge.QueryUserInfo("user")
@ -667,7 +667,7 @@ func TestBridge_User_SendDraftRemoveDraftFlag(t *testing.T) {
} }
// Process those events; the draft will move to the sent folder and lose the draft flag. // Process those events; the draft will move to the sent folder and lose the draft flag.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
userContinueEventProcess(ctx, t, s, bridge) userContinueEventProcess(ctx, t, s, bridge)
info, err := bridge.QueryUserInfo("user") info, err := bridge.QueryUserInfo("user")
@ -697,7 +697,7 @@ func TestBridge_User_DisableEnableAddress(t *testing.T) {
aliasID, err := s.CreateAddress(userID, "alias@"+s.GetDomain(), password) aliasID, err := s.CreateAddress(userID, "alias@"+s.GetDomain(), password)
require.NoError(t, err) require.NoError(t, err)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
require.NoError(t, getErr(bridge.LoginFull(ctx, "user", password, nil, nil))) require.NoError(t, getErr(bridge.LoginFull(ctx, "user", password, nil, nil)))
// Initially we should list the address. // Initially we should list the address.
@ -711,7 +711,7 @@ func TestBridge_User_DisableEnableAddress(t *testing.T) {
require.NoError(t, c.DisableAddress(ctx, aliasID)) require.NoError(t, c.DisableAddress(ctx, aliasID))
}) })
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// Eventually we shouldn't list the address. // Eventually we shouldn't list the address.
require.Eventually(t, func() bool { require.Eventually(t, func() bool {
info, err := bridge.QueryUserInfo("user") info, err := bridge.QueryUserInfo("user")
@ -726,7 +726,7 @@ func TestBridge_User_DisableEnableAddress(t *testing.T) {
require.NoError(t, c.EnableAddress(ctx, aliasID)) require.NoError(t, c.EnableAddress(ctx, aliasID))
}) })
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// Eventually we should list the address. // Eventually we should list the address.
require.Eventually(t, func() bool { require.Eventually(t, func() bool {
info, err := bridge.QueryUserInfo("user") info, err := bridge.QueryUserInfo("user")
@ -753,7 +753,7 @@ func TestBridge_User_CreateDisabledAddress(t *testing.T) {
require.NoError(t, c.DisableAddress(ctx, aliasID)) require.NoError(t, c.DisableAddress(ctx, aliasID))
}) })
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
require.NoError(t, getErr(bridge.LoginFull(ctx, "user", password, nil, nil))) require.NoError(t, getErr(bridge.LoginFull(ctx, "user", password, nil, nil)))
// Initially we shouldn't list the address. // Initially we shouldn't list the address.
@ -766,7 +766,7 @@ func TestBridge_User_CreateDisabledAddress(t *testing.T) {
func TestBridge_User_HandleParentLabelRename(t *testing.T) { func TestBridge_User_HandleParentLabelRename(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
require.NoError(t, getErr(bridge.LoginFull(ctx, username, password, nil, nil))) require.NoError(t, getErr(bridge.LoginFull(ctx, username, password, nil, nil)))
info, err := bridge.QueryUserInfo(username) info, err := bridge.QueryUserInfo(username)

View File

@ -35,12 +35,12 @@ import (
func TestBridge_WithoutUsers(t *testing.T) { func TestBridge_WithoutUsers(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
require.Empty(t, bridge.GetUserIDs()) require.Empty(t, bridge.GetUserIDs())
require.Empty(t, getConnectedUserIDs(t, bridge)) require.Empty(t, getConnectedUserIDs(t, bridge))
}) })
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
require.Empty(t, bridge.GetUserIDs()) require.Empty(t, bridge.GetUserIDs())
require.Empty(t, getConnectedUserIDs(t, bridge)) require.Empty(t, getConnectedUserIDs(t, bridge))
}) })
@ -49,7 +49,7 @@ func TestBridge_WithoutUsers(t *testing.T) {
func TestBridge_Login(t *testing.T) { func TestBridge_Login(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// Login the user. // Login the user.
userID, err := bridge.LoginFull(ctx, username, password, nil, nil) userID, err := bridge.LoginFull(ctx, username, password, nil, nil)
require.NoError(t, err) require.NoError(t, err)
@ -69,7 +69,7 @@ func TestBridge_Login_DropConn(t *testing.T) {
defer func() { _ = dropListener.Close() }() defer func() { _ = dropListener.Close() }()
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// Login the user. // Login the user.
userID, err := bridge.LoginFull(ctx, username, password, nil, nil) userID, err := bridge.LoginFull(ctx, username, password, nil, nil)
require.NoError(t, err) require.NoError(t, err)
@ -96,7 +96,7 @@ func TestBridge_Login_DropConn(t *testing.T) {
return 0, false return 0, false
}) })
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// The user is eventually connected. // The user is eventually connected.
require.Eventually(t, func() bool { require.Eventually(t, func() bool {
return len(bridge.GetUserIDs()) == 1 && len(getConnectedUserIDs(t, bridge)) == 1 return len(bridge.GetUserIDs()) == 1 && len(getConnectedUserIDs(t, bridge)) == 1
@ -107,7 +107,7 @@ func TestBridge_Login_DropConn(t *testing.T) {
func TestBridge_LoginTwice(t *testing.T) { func TestBridge_LoginTwice(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// Login the user. // Login the user.
userID, err := bridge.LoginFull(ctx, username, password, nil, nil) userID, err := bridge.LoginFull(ctx, username, password, nil, nil)
require.NoError(t, err) require.NoError(t, err)
@ -125,7 +125,7 @@ func TestBridge_LoginTwice(t *testing.T) {
func TestBridge_LoginLogoutLogin(t *testing.T) { func TestBridge_LoginLogoutLogin(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// Login the user. // Login the user.
userID := must(bridge.LoginFull(ctx, username, password, nil, nil)) userID := must(bridge.LoginFull(ctx, username, password, nil, nil))
@ -153,7 +153,7 @@ func TestBridge_LoginLogoutLogin(t *testing.T) {
func TestBridge_LoginDeleteLogin(t *testing.T) { func TestBridge_LoginDeleteLogin(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// Login the user. // Login the user.
userID := must(bridge.LoginFull(ctx, username, password, nil, nil)) userID := must(bridge.LoginFull(ctx, username, password, nil, nil))
@ -181,7 +181,7 @@ func TestBridge_LoginDeleteLogin(t *testing.T) {
func TestBridge_LoginDeauthLogin(t *testing.T) { func TestBridge_LoginDeauthLogin(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// Login the user. // Login the user.
userID := must(bridge.LoginFull(ctx, username, password, nil, nil)) userID := must(bridge.LoginFull(ctx, username, password, nil, nil))
@ -215,7 +215,7 @@ func TestBridge_LoginDeauthRestartLogin(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
var userID string var userID string
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// Login the user. // Login the user.
userID = must(bridge.LoginFull(ctx, username, password, nil, nil)) userID = must(bridge.LoginFull(ctx, username, password, nil, nil))
@ -235,7 +235,7 @@ func TestBridge_LoginDeauthRestartLogin(t *testing.T) {
require.IsType(t, events.UserDeauth{}, <-eventCh) require.IsType(t, events.UserDeauth{}, <-eventCh)
}) })
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// The user should be disconnected at startup. // The user should be disconnected at startup.
require.Equal(t, []string{userID}, bridge.GetUserIDs()) require.Equal(t, []string{userID}, bridge.GetUserIDs())
require.Empty(t, getConnectedUserIDs(t, bridge)) require.Empty(t, getConnectedUserIDs(t, bridge))
@ -257,7 +257,7 @@ func TestBridge_LoginExpireLogin(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
s.SetAuthLife(authLife) s.SetAuthLife(authLife)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// Login the user. Its auth will only be valid for a short time. // Login the user. Its auth will only be valid for a short time.
userID := must(bridge.LoginFull(ctx, username, password, nil, nil)) userID := must(bridge.LoginFull(ctx, username, password, nil, nil))
@ -275,7 +275,7 @@ func TestBridge_FailToLoad(t *testing.T) {
var userID string var userID string
// Login the user. // Login the user.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
userID = must(bridge.LoginFull(ctx, username, password, nil, nil)) userID = must(bridge.LoginFull(ctx, username, password, nil, nil))
}) })
@ -283,7 +283,7 @@ func TestBridge_FailToLoad(t *testing.T) {
require.NoError(t, s.RevokeUser(userID)) require.NoError(t, s.RevokeUser(userID))
// When bridge starts, the user will not be logged in. // When bridge starts, the user will not be logged in.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
require.Equal(t, []string{userID}, bridge.GetUserIDs()) require.Equal(t, []string{userID}, bridge.GetUserIDs())
require.Empty(t, getConnectedUserIDs(t, bridge)) require.Empty(t, getConnectedUserIDs(t, bridge))
}) })
@ -295,7 +295,7 @@ func TestBridge_LoadWithoutInternet(t *testing.T) {
var userID string var userID string
// Login the user. // Login the user.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
userID = must(bridge.LoginFull(ctx, username, password, nil, nil)) userID = must(bridge.LoginFull(ctx, username, password, nil, nil))
}) })
@ -303,7 +303,7 @@ func TestBridge_LoadWithoutInternet(t *testing.T) {
netCtl.Disable() netCtl.Disable()
// Start bridge without internet. // Start bridge without internet.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// Initially, users are not connected. // Initially, users are not connected.
require.Equal(t, []string{userID}, bridge.GetUserIDs()) require.Equal(t, []string{userID}, bridge.GetUserIDs())
require.Empty(t, getConnectedUserIDs(t, bridge)) require.Empty(t, getConnectedUserIDs(t, bridge))
@ -325,11 +325,11 @@ func TestBridge_LoginRestart(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
var userID string var userID string
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
userID = must(bridge.LoginFull(ctx, username, password, nil, nil)) userID = must(bridge.LoginFull(ctx, username, password, nil, nil))
}) })
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
require.Equal(t, []string{userID}, bridge.GetUserIDs()) require.Equal(t, []string{userID}, bridge.GetUserIDs())
require.Equal(t, []string{userID}, getConnectedUserIDs(t, bridge)) require.Equal(t, []string{userID}, getConnectedUserIDs(t, bridge))
}) })
@ -340,7 +340,7 @@ func TestBridge_LoginLogoutRestart(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
var userID string var userID string
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// Login the user. // Login the user.
userID = must(bridge.LoginFull(ctx, username, password, nil, nil)) userID = must(bridge.LoginFull(ctx, username, password, nil, nil))
@ -348,7 +348,7 @@ func TestBridge_LoginLogoutRestart(t *testing.T) {
require.NoError(t, bridge.LogoutUser(ctx, userID)) require.NoError(t, bridge.LogoutUser(ctx, userID))
}) })
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// The user is still disconnected. // The user is still disconnected.
require.Equal(t, []string{userID}, bridge.GetUserIDs()) require.Equal(t, []string{userID}, bridge.GetUserIDs())
require.Empty(t, getConnectedUserIDs(t, bridge)) require.Empty(t, getConnectedUserIDs(t, bridge))
@ -360,7 +360,7 @@ func TestBridge_LoginDeleteRestart(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
var userID string var userID string
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// Login the user. // Login the user.
userID = must(bridge.LoginFull(ctx, username, password, nil, nil)) userID = must(bridge.LoginFull(ctx, username, password, nil, nil))
@ -368,7 +368,7 @@ func TestBridge_LoginDeleteRestart(t *testing.T) {
require.NoError(t, bridge.DeleteUser(ctx, userID)) require.NoError(t, bridge.DeleteUser(ctx, userID))
}) })
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// The user is still gone. // The user is still gone.
require.Empty(t, bridge.GetUserIDs()) require.Empty(t, bridge.GetUserIDs())
require.Empty(t, getConnectedUserIDs(t, bridge)) require.Empty(t, getConnectedUserIDs(t, bridge))
@ -384,7 +384,7 @@ func TestBridge_FailLoginRecover(t *testing.T) {
// Log the user in, wait for it to sync, then log it out. // Log the user in, wait for it to sync, then log it out.
// (We don't want to count message sync data in the test.) // (We don't want to count message sync data in the test.)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
syncCh, done := chToType[events.Event, events.SyncFinished](bridge.GetEvents(events.SyncFinished{})) syncCh, done := chToType[events.Event, events.SyncFinished](bridge.GetEvents(events.SyncFinished{}))
defer done() defer done()
@ -396,7 +396,7 @@ func TestBridge_FailLoginRecover(t *testing.T) {
var total uint64 var total uint64
// Now that the user is synced, we can measure exactly how much data is needed during login. // Now that the user is synced, we can measure exactly how much data is needed during login.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
total = countBytesRead(netCtl, func() { total = countBytesRead(netCtl, func() {
must(bridge.LoginFull(ctx, username, password, nil, nil)) must(bridge.LoginFull(ctx, username, password, nil, nil))
}) })
@ -405,7 +405,7 @@ func TestBridge_FailLoginRecover(t *testing.T) {
}) })
// Now simulate failing to login. // Now simulate failing to login.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// Simulate a partial read. // Simulate a partial read.
netCtl.SetReadLimit(i * total / 10) netCtl.SetReadLimit(i * total / 10)
@ -421,7 +421,7 @@ func TestBridge_FailLoginRecover(t *testing.T) {
netCtl.SetReadLimit(0) netCtl.SetReadLimit(0)
// We should now be able to log the user in. // We should now be able to log the user in.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
require.NoError(t, getErr(bridge.LoginFull(ctx, username, password, nil, nil))) require.NoError(t, getErr(bridge.LoginFull(ctx, username, password, nil, nil)))
// The user should be there, now connected. // The user should be there, now connected.
@ -441,7 +441,7 @@ func TestBridge_FailLoadRecover(t *testing.T) {
// Log the user in and wait for it to sync. // Log the user in and wait for it to sync.
// (We don't want to count message sync data in the test.) // (We don't want to count message sync data in the test.)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
syncCh, done := chToType[events.Event, events.SyncFinished](bridge.GetEvents(events.SyncFinished{})) syncCh, done := chToType[events.Event, events.SyncFinished](bridge.GetEvents(events.SyncFinished{}))
defer done() defer done()
@ -451,7 +451,7 @@ func TestBridge_FailLoadRecover(t *testing.T) {
// See how much data it takes to load the user at startup. // See how much data it takes to load the user at startup.
total := countBytesRead(netCtl, func() { total := countBytesRead(netCtl, func() {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(_ *bridge.Bridge, _ *bridge.Mocks) {
// ... // ...
}) })
}) })
@ -460,7 +460,7 @@ func TestBridge_FailLoadRecover(t *testing.T) {
netCtl.SetReadLimit(i * total / 10) netCtl.SetReadLimit(i * total / 10)
// We should fail to load the user; it should be listed but disconnected. // We should fail to load the user; it should be listed but disconnected.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
require.Equal(t, []string{userID}, bridge.GetUserIDs()) require.Equal(t, []string{userID}, bridge.GetUserIDs())
require.Empty(t, getConnectedUserIDs(t, bridge)) require.Empty(t, getConnectedUserIDs(t, bridge))
}) })
@ -469,7 +469,7 @@ func TestBridge_FailLoadRecover(t *testing.T) {
netCtl.SetReadLimit(0) netCtl.SetReadLimit(0)
// We should now be able to load the user. // We should now be able to load the user.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
require.Equal(t, []string{userID}, bridge.GetUserIDs()) require.Equal(t, []string{userID}, bridge.GetUserIDs())
require.Equal(t, []string{userID}, getConnectedUserIDs(t, bridge)) require.Equal(t, []string{userID}, getConnectedUserIDs(t, bridge))
}) })
@ -484,7 +484,7 @@ func TestBridge_BridgePass(t *testing.T) {
var pass []byte var pass []byte
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// Login the user. // Login the user.
userID = must(bridge.LoginFull(ctx, username, password, nil, nil)) userID = must(bridge.LoginFull(ctx, username, password, nil, nil))
@ -501,7 +501,7 @@ func TestBridge_BridgePass(t *testing.T) {
require.Equal(t, pass, pass) require.Equal(t, pass, pass)
}) })
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// The bridge should load the user. // The bridge should load the user.
require.Equal(t, []string{userID}, bridge.GetUserIDs()) require.Equal(t, []string{userID}, bridge.GetUserIDs())
require.Equal(t, []string{userID}, getConnectedUserIDs(t, bridge)) require.Equal(t, []string{userID}, getConnectedUserIDs(t, bridge))
@ -514,7 +514,7 @@ func TestBridge_BridgePass(t *testing.T) {
func TestBridge_AddressMode(t *testing.T) { func TestBridge_AddressMode(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// Login the user. // Login the user.
userID, err := bridge.LoginFull(ctx, username, password, nil, nil) userID, err := bridge.LoginFull(ctx, username, password, nil, nil)
require.NoError(t, err) require.NoError(t, err)
@ -552,7 +552,7 @@ func TestBridge_AddressMode(t *testing.T) {
func TestBridge_LoginLogoutRepeated(t *testing.T) { func TestBridge_LoginLogoutRepeated(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
for i := 0; i < 10; i++ { for i := 0; i < 10; i++ {
// Log the user in. // Log the user in.
userID := must(bridge.LoginFull(ctx, username, password, nil, nil)) userID := must(bridge.LoginFull(ctx, username, password, nil, nil))
@ -568,7 +568,7 @@ func TestBridge_LogoutOffline(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
var userID string var userID string
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// Login the user. // Login the user.
userID = must(bridge.LoginFull(ctx, username, password, nil, nil)) userID = must(bridge.LoginFull(ctx, username, password, nil, nil))
@ -590,7 +590,7 @@ func TestBridge_LogoutOffline(t *testing.T) {
// Go back online. // Go back online.
netCtl.Enable() netCtl.Enable()
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// The user is still disconnected. // The user is still disconnected.
require.Equal(t, []string{userID}, bridge.GetUserIDs()) require.Equal(t, []string{userID}, bridge.GetUserIDs())
require.Empty(t, getConnectedUserIDs(t, bridge)) require.Empty(t, getConnectedUserIDs(t, bridge))
@ -600,7 +600,7 @@ func TestBridge_LogoutOffline(t *testing.T) {
func TestBridge_DeleteDisconnected(t *testing.T) { func TestBridge_DeleteDisconnected(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// Login the user. // Login the user.
userID, err := bridge.LoginFull(ctx, username, password, nil, nil) userID, err := bridge.LoginFull(ctx, username, password, nil, nil)
require.NoError(t, err) require.NoError(t, err)
@ -628,7 +628,7 @@ func TestBridge_DeleteDisconnected(t *testing.T) {
func TestBridge_DeleteOffline(t *testing.T) { func TestBridge_DeleteOffline(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// Login the user. // Login the user.
userID, err := bridge.LoginFull(ctx, username, password, nil, nil) userID, err := bridge.LoginFull(ctx, username, password, nil, nil)
require.NoError(t, err) require.NoError(t, err)
@ -652,7 +652,7 @@ func TestBridge_DeleteOffline(t *testing.T) {
func TestBridge_UserInfo_Alias(t *testing.T) { func TestBridge_UserInfo_Alias(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// Create a new user. // Create a new user.
userID, _, err := s.CreateUser("primary", []byte("password")) userID, _, err := s.CreateUser("primary", []byte("password"))
require.NoError(t, err) require.NoError(t, err)
@ -675,7 +675,7 @@ func TestBridge_UserInfo_Alias(t *testing.T) {
func TestBridge_User_Refresh(t *testing.T) { func TestBridge_User_Refresh(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
// Get a channel of sync started events. // Get a channel of sync started events.
syncStartCh, done := chToType[events.Event, events.SyncStarted](bridge.GetEvents(events.SyncStarted{})) syncStartCh, done := chToType[events.Event, events.SyncStarted](bridge.GetEvents(events.SyncStarted{}))
defer done() defer done()

View File

@ -151,7 +151,7 @@ func getClientWithJar(t *testing.T, persister Persister) (*http.Client, *Jar) {
func getTestServer(t *testing.T, wantCookies []testCookie) *httptest.Server { func getTestServer(t *testing.T, wantCookies []testCookie) *httptest.Server {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/set", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/set", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
for _, cookie := range wantCookies { for _, cookie := range wantCookies {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: cookie.name, Name: cookie.name,

View File

@ -26,7 +26,7 @@ import (
// ShowErrorNotification shows a system notification that the app with the given appName has crashed. // ShowErrorNotification shows a system notification that the app with the given appName has crashed.
// NOTE: Icons shouldn't be hardcoded. // NOTE: Icons shouldn't be hardcoded.
func ShowErrorNotification(appName string) RecoveryAction { func ShowErrorNotification(appName string) RecoveryAction {
return func(r interface{}) error { return func(_ interface{}) error {
notify := notificator.New(notificator.Options{ notify := notificator.New(notificator.Options{
DefaultIcon: "../frontend/ui/icon/icon.png", DefaultIcon: "../frontend/ui/icon/icon.png",
AppName: appName, AppName: appName,

View File

@ -31,7 +31,7 @@ import (
func TestTLSReporter_DoubleReport(t *testing.T) { func TestTLSReporter_DoubleReport(t *testing.T) {
reportCounter := 0 reportCounter := 0
reportServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reportServer := httptest.NewTLSServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
reportCounter++ reportCounter++
})) }))

View File

@ -33,7 +33,7 @@ func TestProxyProvider_FindProxy(t *testing.T) {
defer closeServer(proxy) defer closeServer(proxy)
p := newProxyProvider(NewBasicTLSDialer(""), "", []string{"not used"}, async.NoopPanicHandler{}) p := newProxyProvider(NewBasicTLSDialer(""), "", []string{"not used"}, async.NoopPanicHandler{})
p.dohLookup = func(ctx context.Context, q, p string) ([]string, error) { return []string{proxy.URL}, nil } p.dohLookup = func(_ context.Context, _, _ string) ([]string, error) { return []string{proxy.URL}, nil }
url, err := p.findReachableServer() url, err := p.findReachableServer()
r.NoError(t, err) r.NoError(t, err)
@ -49,7 +49,7 @@ func TestProxyProvider_FindProxy_ChooseReachableProxy(t *testing.T) {
closeServer(unreachableProxy) closeServer(unreachableProxy)
p := newProxyProvider(NewBasicTLSDialer(""), "", []string{"not used"}, async.NoopPanicHandler{}) p := newProxyProvider(NewBasicTLSDialer(""), "", []string{"not used"}, async.NoopPanicHandler{})
p.dohLookup = func(ctx context.Context, q, p string) ([]string, error) { p.dohLookup = func(_ context.Context, _, _ string) ([]string, error) {
return []string{reachableProxy.URL, unreachableProxy.URL}, nil return []string{reachableProxy.URL, unreachableProxy.URL}, nil
} }
@ -70,7 +70,7 @@ func TestProxyProvider_FindProxy_ChooseTrustedProxy(t *testing.T) {
dialer := NewPinningTLSDialer(NewBasicTLSDialer(""), reporter, checker) dialer := NewPinningTLSDialer(NewBasicTLSDialer(""), reporter, checker)
p := newProxyProvider(dialer, "", []string{"not used"}, async.NoopPanicHandler{}) p := newProxyProvider(dialer, "", []string{"not used"}, async.NoopPanicHandler{})
p.dohLookup = func(ctx context.Context, q, p string) ([]string, error) { p.dohLookup = func(_ context.Context, _, _ string) ([]string, error) {
return []string{untrustedProxy.URL, trustedProxy.URL}, nil return []string{untrustedProxy.URL, trustedProxy.URL}, nil
} }
@ -87,7 +87,7 @@ func TestProxyProvider_FindProxy_FailIfNoneReachable(t *testing.T) {
closeServer(unreachableProxy2) closeServer(unreachableProxy2)
p := newProxyProvider(NewBasicTLSDialer(""), "", []string{"not used"}, async.NoopPanicHandler{}) p := newProxyProvider(NewBasicTLSDialer(""), "", []string{"not used"}, async.NoopPanicHandler{})
p.dohLookup = func(ctx context.Context, q, p string) ([]string, error) { p.dohLookup = func(_ context.Context, _, _ string) ([]string, error) {
return []string{unreachableProxy1.URL, unreachableProxy2.URL}, nil return []string{unreachableProxy1.URL, unreachableProxy2.URL}, nil
} }
@ -107,7 +107,7 @@ func TestProxyProvider_FindProxy_FailIfNoneTrusted(t *testing.T) {
dialer := NewPinningTLSDialer(NewBasicTLSDialer(""), reporter, checker) dialer := NewPinningTLSDialer(NewBasicTLSDialer(""), reporter, checker)
p := newProxyProvider(dialer, "", []string{"not used"}, async.NoopPanicHandler{}) p := newProxyProvider(dialer, "", []string{"not used"}, async.NoopPanicHandler{})
p.dohLookup = func(ctx context.Context, q, p string) ([]string, error) { p.dohLookup = func(_ context.Context, _, _ string) ([]string, error) {
return []string{untrustedProxy1.URL, untrustedProxy2.URL}, nil return []string{untrustedProxy1.URL, untrustedProxy2.URL}, nil
} }
@ -118,7 +118,7 @@ func TestProxyProvider_FindProxy_FailIfNoneTrusted(t *testing.T) {
func TestProxyProvider_FindProxy_RefreshCacheTimeout(t *testing.T) { func TestProxyProvider_FindProxy_RefreshCacheTimeout(t *testing.T) {
p := newProxyProvider(NewBasicTLSDialer(""), "", []string{"not used"}, async.NoopPanicHandler{}) p := newProxyProvider(NewBasicTLSDialer(""), "", []string{"not used"}, async.NoopPanicHandler{})
p.cacheRefreshTimeout = 1 * time.Second p.cacheRefreshTimeout = 1 * time.Second
p.dohLookup = func(ctx context.Context, q, p string) ([]string, error) { time.Sleep(2 * time.Second); return nil, nil } p.dohLookup = func(_ context.Context, _, _ string) ([]string, error) { time.Sleep(2 * time.Second); return nil, nil }
// We should fail to refresh the proxy cache because the doh provider // We should fail to refresh the proxy cache because the doh provider
// takes 2 seconds to respond but we timeout after just 1 second. // takes 2 seconds to respond but we timeout after just 1 second.
@ -135,7 +135,7 @@ func TestProxyProvider_FindProxy_CanReachTimeout(t *testing.T) {
p := newProxyProvider(NewBasicTLSDialer(""), "", []string{"not used"}, async.NoopPanicHandler{}) p := newProxyProvider(NewBasicTLSDialer(""), "", []string{"not used"}, async.NoopPanicHandler{})
p.canReachTimeout = 1 * time.Second p.canReachTimeout = 1 * time.Second
p.dohLookup = func(ctx context.Context, q, p string) ([]string, error) { return []string{slowProxy.URL}, nil } p.dohLookup = func(_ context.Context, _, _ string) ([]string, error) { return []string{slowProxy.URL}, nil }
// We should fail to reach the returned proxy because it takes 2 seconds // We should fail to reach the returned proxy because it takes 2 seconds
// to reach it and we only allow 1. // to reach it and we only allow 1.

View File

@ -112,7 +112,7 @@ vwRMog6lPhlRhHh/FZ43Cg==
// getUntrustedServer returns a server but it doesn't add its public key to the list of pinned ones. // getUntrustedServer returns a server but it doesn't add its public key to the list of pinned ones.
func getUntrustedServer() *httptest.Server { func getUntrustedServer() *httptest.Server {
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) server := httptest.NewUnstartedServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}))
cert, err := tls.X509KeyPair([]byte(servercrt), []byte(serverkey)) cert, err := tls.X509KeyPair([]byte(servercrt), []byte(serverkey))
if err != nil { if err != nil {
@ -145,7 +145,7 @@ func TestProxyDialer_UseProxy(t *testing.T) {
provider := newProxyProvider(NewBasicTLSDialer(""), "", DoHProviders, async.NoopPanicHandler{}) provider := newProxyProvider(NewBasicTLSDialer(""), "", DoHProviders, async.NoopPanicHandler{})
d := NewProxyTLSDialer(NewBasicTLSDialer(""), "", async.NoopPanicHandler{}) d := NewProxyTLSDialer(NewBasicTLSDialer(""), "", async.NoopPanicHandler{})
d.proxyProvider = provider d.proxyProvider = provider
provider.dohLookup = func(ctx context.Context, q, p string) ([]string, error) { return []string{trustedProxy.URL}, nil } provider.dohLookup = func(_ context.Context, _, _ string) ([]string, error) { return []string{trustedProxy.URL}, nil }
err := d.switchToReachableServer() err := d.switchToReachableServer()
require.NoError(t, err) require.NoError(t, err)
@ -163,7 +163,7 @@ func TestProxyDialer_UseProxy_MultipleTimes(t *testing.T) {
provider := newProxyProvider(NewBasicTLSDialer(""), "", DoHProviders, async.NoopPanicHandler{}) provider := newProxyProvider(NewBasicTLSDialer(""), "", DoHProviders, async.NoopPanicHandler{})
d := NewProxyTLSDialer(NewBasicTLSDialer(""), "", async.NoopPanicHandler{}) d := NewProxyTLSDialer(NewBasicTLSDialer(""), "", async.NoopPanicHandler{})
d.proxyProvider = provider d.proxyProvider = provider
provider.dohLookup = func(ctx context.Context, q, p string) ([]string, error) { return []string{proxy1.URL}, nil } provider.dohLookup = func(_ context.Context, _, _ string) ([]string, error) { return []string{proxy1.URL}, nil }
err := d.switchToReachableServer() err := d.switchToReachableServer()
require.NoError(t, err) require.NoError(t, err)
@ -172,7 +172,7 @@ func TestProxyDialer_UseProxy_MultipleTimes(t *testing.T) {
// Have to wait so as to not get rejected. // Have to wait so as to not get rejected.
time.Sleep(proxyLookupWait) time.Sleep(proxyLookupWait)
provider.dohLookup = func(ctx context.Context, q, p string) ([]string, error) { return []string{proxy2.URL}, nil } provider.dohLookup = func(_ context.Context, _, _ string) ([]string, error) { return []string{proxy2.URL}, nil }
err = d.switchToReachableServer() err = d.switchToReachableServer()
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, formatAsAddress(proxy2.URL), d.proxyAddress) require.Equal(t, formatAsAddress(proxy2.URL), d.proxyAddress)
@ -180,7 +180,7 @@ func TestProxyDialer_UseProxy_MultipleTimes(t *testing.T) {
// Have to wait so as to not get rejected. // Have to wait so as to not get rejected.
time.Sleep(proxyLookupWait) time.Sleep(proxyLookupWait)
provider.dohLookup = func(ctx context.Context, q, p string) ([]string, error) { return []string{proxy3.URL}, nil } provider.dohLookup = func(_ context.Context, _, _ string) ([]string, error) { return []string{proxy3.URL}, nil }
err = d.switchToReachableServer() err = d.switchToReachableServer()
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, formatAsAddress(proxy3.URL), d.proxyAddress) require.Equal(t, formatAsAddress(proxy3.URL), d.proxyAddress)
@ -195,7 +195,7 @@ func TestProxyDialer_UseProxy_RevertAfterTime(t *testing.T) {
d.proxyProvider = provider d.proxyProvider = provider
d.proxyUseDuration = time.Second d.proxyUseDuration = time.Second
provider.dohLookup = func(ctx context.Context, q, p string) ([]string, error) { return []string{trustedProxy.URL}, nil } provider.dohLookup = func(_ context.Context, _, _ string) ([]string, error) { return []string{trustedProxy.URL}, nil }
err := d.switchToReachableServer() err := d.switchToReachableServer()
require.NoError(t, err) require.NoError(t, err)
@ -216,7 +216,7 @@ func TestProxyDialer_UseProxy_RevertIfProxyStopsWorkingAndOriginalAPIIsReachable
provider := newProxyProvider(NewBasicTLSDialer(""), "", DoHProviders, async.NoopPanicHandler{}) provider := newProxyProvider(NewBasicTLSDialer(""), "", DoHProviders, async.NoopPanicHandler{})
d := NewProxyTLSDialer(NewBasicTLSDialer(""), "", async.NoopPanicHandler{}) d := NewProxyTLSDialer(NewBasicTLSDialer(""), "", async.NoopPanicHandler{})
d.proxyProvider = provider d.proxyProvider = provider
provider.dohLookup = func(ctx context.Context, q, p string) ([]string, error) { return []string{trustedProxy.URL}, nil } provider.dohLookup = func(_ context.Context, _, _ string) ([]string, error) { return []string{trustedProxy.URL}, nil }
err := d.switchToReachableServer() err := d.switchToReachableServer()
require.NoError(t, err) require.NoError(t, err)
@ -246,7 +246,7 @@ func TestProxyDialer_UseProxy_FindSecondAlternativeIfFirstFailsAndAPIIsStillBloc
provider := newProxyProvider(NewBasicTLSDialer(""), "", DoHProviders, async.NoopPanicHandler{}) provider := newProxyProvider(NewBasicTLSDialer(""), "", DoHProviders, async.NoopPanicHandler{})
d := NewProxyTLSDialer(NewBasicTLSDialer(""), "", async.NoopPanicHandler{}) d := NewProxyTLSDialer(NewBasicTLSDialer(""), "", async.NoopPanicHandler{})
d.proxyProvider = provider d.proxyProvider = provider
provider.dohLookup = func(ctx context.Context, q, p string) ([]string, error) { return []string{proxy1.URL, proxy2.URL}, nil } provider.dohLookup = func(_ context.Context, _, _ string) ([]string, error) { return []string{proxy1.URL, proxy2.URL}, nil }
err := d.switchToReachableServer() err := d.switchToReachableServer()
require.NoError(t, err) require.NoError(t, err)

View File

@ -212,3 +212,16 @@ type UserLoadedCheckResync struct {
func (event UserLoadedCheckResync) String() string { func (event UserLoadedCheckResync) String() string {
return fmt.Sprintf("UserLoadedCheckResync: UserID: %s", event.UserID) return fmt.Sprintf("UserLoadedCheckResync: UserID: %s", event.UserID)
} }
type UserNotification struct {
eventBase
UserID string
Title string
Subtitle string
Body string
}
func (event UserNotification) String() string {
return fmt.Sprintf("UserNotification: UserID: %s, Title: %s, Subtitle: %s, Body: %s", event.UserID, event.Title, event.Subtitle, event.Body)
}

View File

@ -57,6 +57,7 @@ UsersTab::UsersTab(QWidget *parent)
connect(ui_.checkUsernamePasswordError, &QCheckBox::toggled, this, &UsersTab::updateGUIState); connect(ui_.checkUsernamePasswordError, &QCheckBox::toggled, this, &UsersTab::updateGUIState);
connect(ui_.checkSync, &QCheckBox::toggled, this, &UsersTab::onCheckSyncToggled); connect(ui_.checkSync, &QCheckBox::toggled, this, &UsersTab::onCheckSyncToggled);
connect(ui_.sliderSync, &QSlider::valueChanged, this, &UsersTab::onSliderSyncValueChanged); connect(ui_.sliderSync, &QSlider::valueChanged, this, &UsersTab::onSliderSyncValueChanged);
connect(ui_.sendNotificationButton, &QPushButton::clicked, this, &UsersTab::onSendUserNotification);
users_.append(defaultUser()); users_.append(defaultUser());
@ -216,6 +217,7 @@ void UsersTab::updateGUIState() {
ui_.editUsernamePasswordError->setEnabled(ui_.checkUsernamePasswordError->isChecked()); ui_.editUsernamePasswordError->setEnabled(ui_.checkUsernamePasswordError->isChecked());
ui_.spinUsedBytes->setValue(user ? user->usedBytes() : 0.0); ui_.spinUsedBytes->setValue(user ? user->usedBytes() : 0.0);
ui_.groupboxSync->setEnabled(user.get()); ui_.groupboxSync->setEnabled(user.get());
ui_.groupBoxNotification->setEnabled(hasSelectedUser && (UserState::Connected == state));
if (user) if (user)
ui_.editIMAPLoginFailedUsername->setText(user->primaryEmailOrUsername()); ui_.editIMAPLoginFailedUsername->setText(user->primaryEmailOrUsername());
@ -489,3 +491,41 @@ void UsersTab::onSliderSyncValueChanged(int value) {
app().grpc().sendEvent(newSyncProgressEvent(user->id(), progress, 1, 1)); // we do not simulate elapsed & remaining. app().grpc().sendEvent(newSyncProgressEvent(user->id(), progress, 1, 1)); // we do not simulate elapsed & remaining.
this->updateGUIState(); this->updateGUIState();
} }
//****************************************************************************************************************************************************
/// \return the title for the notification.
//****************************************************************************************************************************************************
QString UsersTab::notificationTitle() const {
return ui_.notificationTitle->text();
}
//****************************************************************************************************************************************************
/// \return the subtitle for the notification.
//****************************************************************************************************************************************************
QString UsersTab::notificationSubtitle() const {
return ui_.notificationSubtitleText->text();
}
//****************************************************************************************************************************************************
/// \return the body for the notification.
//****************************************************************************************************************************************************
QString UsersTab::notificationBody() const {
return ui_.notticationBodyText->text();
}
void UsersTab::onSendUserNotification() {
SPUser const user = selectedUser();
if (!user) {
app().log().error(QString("%1 failed. Unkown user.").arg(__FUNCTION__));
return;
}
GRPCService &grpc = app().grpc();
if (grpc.isStreaming()) {
QString const userID = user->id();
grpc.sendEvent(newUserNotificationEvent(userID, notificationTitle(), notificationSubtitle(), notificationBody()));
}
}

View File

@ -23,7 +23,6 @@
#include "Tabs/ui_UsersTab.h" #include "Tabs/ui_UsersTab.h"
#include "UserTable.h" #include "UserTable.h"
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
/// \brief The 'Users' tab of the main window. /// \brief The 'Users' tab of the main window.
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
@ -50,12 +49,15 @@ public: // member functions.
bool nextUserTwoPasswordsError() const; ///< Check if next user login should trigger 2nd password error. bool nextUserTwoPasswordsError() const; ///< Check if next user login should trigger 2nd password error.
bool nextUserTwoPasswordsAbort() const; ///< Check if next user login should trigger 2nd password abort. bool nextUserTwoPasswordsAbort() const; ///< Check if next user login should trigger 2nd password abort.
QString usernamePasswordErrorMessage() const; ///< Return the username password error message. QString usernamePasswordErrorMessage() const; ///< Return the username password error message.
QString notificationTitle() const; ///< Return the user notification title.
QString notificationSubtitle() const; ///< Return the user notification subtitle.
QString notificationBody() const; ///< Return the user notification body.
public slots: public slots:
void setUserSplitMode(QString const &userID, bool makeItActive); ///< Slot for the split mode. void setUserSplitMode(QString const &userID, bool makeItActive); ///< Slot for the split mode.
void logoutUser(QString const &userID); ///< slot for the logging out of a user. void logoutUser(QString const &userID); ///< slot for the logging out of a user.
void removeUser(QString const &userID); ///< Slot for the removal of a user. void removeUser(QString const &userID); ///< Slot for the removal of a user.
static void configureUserAppleMail(QString const &userID, QString const &address); ///< Slot for the configuration of Apple mail. static void configureUserAppleMail(QString const &userID, QString const &address); ///< Slot for the configuration of Apple mail.
void processBadEventUserFeedback(QString const& userID, bool doResync); ///< Slot for the reception of a bad event user feedback. void processBadEventUserFeedback(QString const& userID, bool doResync); ///< Slot for the reception of a bad event user feedback.
private slots: private slots:
@ -69,6 +71,7 @@ private slots:
void onCheckSyncToggled(bool checked); ///< Slot for the 'Synchronizing' check box. void onCheckSyncToggled(bool checked); ///< Slot for the 'Synchronizing' check box.
void onSliderSyncValueChanged(int value); ///< Slot for the sync 'Progress' slider. void onSliderSyncValueChanged(int value); ///< Slot for the sync 'Progress' slider.
void updateGUIState(); ///< Update the GUI state. void updateGUIState(); ///< Update the GUI state.
void onSendUserNotification(); ///< Send a user notification event to the GUI.
private: // member functions. private: // member functions.
qint32 selectedIndex() const; ///< Get the index of the selected row. qint32 selectedIndex() const; ///< Get the index of the selected row.

View File

@ -7,13 +7,19 @@
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>1221</width> <width>1221</width>
<height>894</height> <height>408</height>
</rect> </rect>
</property> </property>
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="windowTitle"> <property name="windowTitle">
<string>Form</string> <string>Form</string>
</property> </property>
<layout class="QHBoxLayout" name="horizontalLayout" stretch="1,0"> <layout class="QHBoxLayout" name="horizontalLayout" stretch="0,0">
<item> <item>
<widget class="QTableView" name="tableUserList"> <widget class="QTableView" name="tableUserList">
<property name="selectionMode"> <property name="selectionMode">
@ -31,332 +37,419 @@
</widget> </widget>
</item> </item>
<item> <item>
<layout class="QVBoxLayout" name="verticalLayout"> <widget class="QScrollArea" name="scrollArea">
<item> <property name="sizePolicy">
<widget class="QPushButton" name="buttonNewUser"> <sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<property name="text"> <horstretch>0</horstretch>
<string>New User</string> <verstretch>0</verstretch>
</property> </sizepolicy>
</widget> </property>
</item> <property name="widgetResizable">
<item> <bool>true</bool>
<widget class="QPushButton" name="buttonEditUser"> </property>
<property name="text"> <widget class="QWidget" name="scrollAreaWidgetContents">
<string>Edit User</string> <property name="geometry">
</property> <rect>
</widget> <x>0</x>
</item> <y>0</y>
<item> <width>327</width>
<widget class="QPushButton" name="buttonRemoveUser"> <height>905</height>
<property name="text"> </rect>
<string>Remove User</string> </property>
</property> <layout class="QHBoxLayout" name="horizontalLayout_6">
</widget> <item>
</item> <layout class="QVBoxLayout" name="verticalLayout">
<item> <item>
<spacer name="verticalSpacer"> <widget class="QPushButton" name="buttonNewUser">
<property name="orientation"> <property name="text">
<enum>Qt::Vertical</enum> <string>New User</string>
</property> </property>
<property name="sizeHint" stdset="0"> </widget>
<size> </item>
<width>20</width> <item>
<height>40</height> <widget class="QPushButton" name="buttonEditUser">
</size> <property name="text">
</property> <string>Edit User</string>
</spacer> </property>
</item> </widget>
<item> </item>
<widget class="QGroupBox" name="groupboxSync"> <item>
<property name="minimumSize"> <widget class="QPushButton" name="buttonRemoveUser">
<size> <property name="text">
<width>0</width> <string>Remove User</string>
<height>0</height> </property>
</size> </widget>
</property> </item>
<property name="title"> <item>
<string>Sync</string> <spacer name="verticalSpacer">
</property> <property name="orientation">
<layout class="QVBoxLayout" name="verticalLayout_5"> <enum>Qt::Vertical</enum>
<item> </property>
<layout class="QHBoxLayout" name="horizontalLayout_4" stretch="1,0"> <property name="sizeHint" stdset="0">
<item> <size>
<widget class="QCheckBox" name="checkSync"> <width>20</width>
<property name="text"> <height>40</height>
<string>Synchronizing</string> </size>
</property> </property>
</widget> </spacer>
</item> </item>
<item> <item>
<widget class="QLabel" name="labelSync"> <widget class="QGroupBox" name="groupBoxNotification">
<property name="text"> <property name="enabled">
<string>0%</string> <bool>true</bool>
</property> </property>
</widget> <property name="minimumSize">
</item> <size>
</layout> <width>300</width>
</item> <height>0</height>
<item> </size>
<widget class="QSlider" name="sliderSync"> </property>
<property name="maximum"> <property name="maximumSize">
<number>100</number> <size>
</property> <width>300</width>
<property name="orientation"> <height>400</height>
<enum>Qt::Horizontal</enum> </size>
</property> </property>
<property name="tickInterval"> <property name="title">
<number>10</number> <string>Notification</string>
</property> </property>
</widget> <layout class="QVBoxLayout" name="verticalLayout_9">
</item> <item>
</layout> <layout class="QVBoxLayout" name="verticalLayout_6" stretch="0,0,0,0">
</widget> <item>
</item> <widget class="QLineEdit" name="notificationTitle">
<item> <property name="placeholderText">
<widget class="QGroupBox" name="groupBoxBadEvent"> <string>Title</string>
<property name="minimumSize"> </property>
<size> </widget>
<width>0</width> </item>
<height>0</height> <item>
</size> <widget class="QLineEdit" name="notificationSubtitleText">
</property> <property name="minimumSize">
<property name="title"> <size>
<string>Bad Event</string> <width>0</width>
</property> <height>0</height>
<layout class="QVBoxLayout" name="verticalLayout_3"> </size>
<item> </property>
<layout class="QHBoxLayout" name="horizontalLayout_3"> <property name="placeholderText">
<item> <string>Subtitle</string>
<widget class="QLineEdit" name="editUserBadEvent"> </property>
<property name="minimumSize"> </widget>
<size> </item>
<width>200</width> <item>
<height>0</height> <widget class="QLineEdit" name="notticationBodyText">
</size> <property name="placeholderText">
</property> <string>Body</string>
<property name="text"> </property>
<string/> </widget>
</property> </item>
<property name="placeholderText"> <item>
<string>error message</string> <widget class="QPushButton" name="sendNotificationButton">
</property> <property name="text">
</widget> <string>Send</string>
</item> </property>
<item> </widget>
<widget class="QPushButton" name="buttonUserBadEvent"> </item>
<property name="text"> </layout>
<string>Send</string> </item>
</property> </layout>
</widget> </widget>
</item> </item>
</layout> <item>
</item> <widget class="QGroupBox" name="groupboxSync">
</layout> <property name="minimumSize">
</widget> <size>
</item> <width>0</width>
<item> <height>0</height>
<widget class="QGroupBox" name="groupBoxUsedSpace"> </size>
<property name="minimumSize"> </property>
<size> <property name="title">
<width>0</width> <string>Sync</string>
<height>0</height> </property>
</size> <layout class="QVBoxLayout" name="verticalLayout_5">
</property> <item>
<property name="title"> <layout class="QHBoxLayout" name="horizontalLayout_4" stretch="1,0">
<string>Used Bytes Changed</string> <item>
</property> <widget class="QCheckBox" name="checkSync">
<layout class="QVBoxLayout" name="verticalLayout_4"> <property name="text">
<item> <string>Synchronizing</string>
<layout class="QHBoxLayout" name="hBoxUsedBytes" stretch="1,0"> </property>
<item> </widget>
<widget class="QDoubleSpinBox" name="spinUsedBytes"> </item>
<property name="buttonSymbols"> <item>
<enum>QAbstractSpinBox::NoButtons</enum> <widget class="QLabel" name="labelSync">
</property> <property name="text">
<property name="decimals"> <string>0%</string>
<number>0</number> </property>
</property> </widget>
<property name="maximum"> </item>
<double>1000000000000000.000000000000000</double> </layout>
</property> </item>
</widget> <item>
</item> <widget class="QSlider" name="sliderSync">
<item> <property name="maximum">
<widget class="QPushButton" name="buttonUsedBytesChanged"> <number>100</number>
<property name="text"> </property>
<string>Send</string> <property name="orientation">
</property> <enum>Qt::Horizontal</enum>
</widget> </property>
</item> <property name="tickInterval">
</layout> <number>10</number>
</item> </property>
</layout> </widget>
</widget> </item>
</item> </layout>
<item> </widget>
<widget class="QGroupBox" name="groupBoxIMAPLoginFailed"> </item>
<property name="minimumSize"> <item>
<size> <widget class="QGroupBox" name="groupBoxBadEvent">
<width>0</width> <property name="minimumSize">
<height>0</height> <size>
</size> <width>0</width>
</property> <height>0</height>
<property name="title"> </size>
<string>IMAP Login Failure</string> </property>
</property> <property name="title">
<layout class="QVBoxLayout" name="verticalLayout_8"> <string>Bad Event</string>
<item> </property>
<layout class="QHBoxLayout" name="horizontalLayout_7"> <layout class="QVBoxLayout" name="verticalLayout_3">
<item> <item>
<widget class="QLineEdit" name="editIMAPLoginFailedUsername"> <layout class="QHBoxLayout" name="horizontalLayout_3">
<property name="minimumSize"> <item>
<size> <widget class="QLineEdit" name="editUserBadEvent">
<width>200</width> <property name="minimumSize">
<height>0</height> <size>
</size> <width>200</width>
</property> <height>0</height>
<property name="text"> </size>
<string/> </property>
</property> <property name="text">
<property name="placeholderText"> <string/>
<string>username or primary email</string> </property>
</property> <property name="placeholderText">
</widget> <string>error message</string>
</item> </property>
<item> </widget>
<widget class="QPushButton" name="buttonImapLoginFailed"> </item>
<property name="text"> <item>
<string>Send</string> <widget class="QPushButton" name="buttonUserBadEvent">
</property> <property name="text">
</widget> <string>Send</string>
</item> </property>
</layout> </widget>
</item> </item>
</layout> </layout>
</widget> </item>
</item> </layout>
<item> </widget>
<widget class="QGroupBox" name="groupBoxNextLogin"> </item>
<property name="minimumSize"> <item>
<size> <widget class="QGroupBox" name="groupBoxUsedSpace">
<width>0</width> <property name="minimumSize">
<height>100</height> <size>
</size> <width>0</width>
</property> <height>0</height>
<property name="title"> </size>
<string>Next Login Attempt</string> </property>
</property> <property name="title">
<layout class="QVBoxLayout" name="verticalLayout_2"> <string>Used Bytes Changed</string>
<item> </property>
<widget class="QCheckBox" name="checkUsernamePasswordError"> <layout class="QVBoxLayout" name="verticalLayout_4">
<property name="text"> <item>
<string>Username/password error:</string> <layout class="QHBoxLayout" name="hBoxUsedBytes" stretch="1,0">
</property> <item>
</widget> <widget class="QDoubleSpinBox" name="spinUsedBytes">
</item> <property name="buttonSymbols">
<item> <enum>QAbstractSpinBox::NoButtons</enum>
<layout class="QHBoxLayout" name="horizontalLayout_2"> </property>
<property name="spacing"> <property name="decimals">
<number>0</number> <number>0</number>
</property> </property>
<item> <property name="maximum">
<spacer name="horizontalSpacer"> <double>1000000000000000.000000000000000</double>
<property name="orientation"> </property>
<enum>Qt::Horizontal</enum> </widget>
</property> </item>
<property name="sizeType"> <item>
<enum>QSizePolicy::Fixed</enum> <widget class="QPushButton" name="buttonUsedBytesChanged">
</property> <property name="text">
<property name="sizeHint" stdset="0"> <string>Send</string>
<size> </property>
<width>20</width> </widget>
<height>10</height> </item>
</size> </layout>
</property> </item>
</spacer> </layout>
</item> </widget>
<item> </item>
<widget class="QLineEdit" name="editUsernamePasswordError"> <item>
<property name="minimumSize"> <widget class="QGroupBox" name="groupBoxIMAPLoginFailed">
<size> <property name="minimumSize">
<width>200</width> <size>
<height>0</height> <width>0</width>
</size> <height>0</height>
</property> </size>
<property name="text"> </property>
<string>Username/password error.</string> <property name="title">
</property> <string>IMAP Login Failure</string>
</widget> </property>
</item> <layout class="QVBoxLayout" name="verticalLayout_8">
</layout> <item>
</item> <layout class="QHBoxLayout" name="horizontalLayout_7">
<item> <item>
<widget class="QCheckBox" name="checkHV3Required"> <widget class="QLineEdit" name="editIMAPLoginFailedUsername">
<property name="text"> <property name="minimumSize">
<string>HV3 required</string> <size>
</property> <width>200</width>
</widget> <height>0</height>
</item> </size>
<item> </property>
<widget class="QCheckBox" name="checkHV3Error"> <property name="text">
<property name="text"> <string/>
<string>HV3 error</string> </property>
</property> <property name="placeholderText">
</widget> <string>username or primary email</string>
</item> </property>
<item> </widget>
<widget class="QCheckBox" name="checkFreeUserError"> </item>
<property name="text"> <item>
<string>Free user error</string> <widget class="QPushButton" name="buttonImapLoginFailed">
</property> <property name="text">
</widget> <string>Send</string>
</item> </property>
<item> </widget>
<widget class="QCheckBox" name="checkTFARequired"> </item>
<property name="text"> </layout>
<string>2FA required</string> </item>
</property> </layout>
</widget> </widget>
</item> </item>
<item> <item>
<widget class="QCheckBox" name="checkTFAError"> <widget class="QGroupBox" name="groupBoxNextLogin">
<property name="text"> <property name="minimumSize">
<string>2FA error</string> <size>
</property> <width>0</width>
</widget> <height>250</height>
</item> </size>
<item> </property>
<widget class="QCheckBox" name="checkTFAAbort"> <property name="title">
<property name="text"> <string>Next Login Attempt</string>
<string>2FA abort</string> </property>
</property> <layout class="QVBoxLayout" name="verticalLayout_2">
</widget> <item>
</item> <widget class="QCheckBox" name="checkUsernamePasswordError">
<item> <property name="text">
<widget class="QCheckBox" name="checkTwoPasswordsRequired"> <string>Username/password error:</string>
<property name="text"> </property>
<string>2nd password required</string> </widget>
</property> </item>
</widget> <item>
</item> <layout class="QHBoxLayout" name="horizontalLayout_2">
<item> <property name="spacing">
<widget class="QCheckBox" name="checkTwoPasswordsError"> <number>0</number>
<property name="text"> </property>
<string>2nd password error</string> <item>
</property> <spacer name="horizontalSpacer">
</widget> <property name="orientation">
</item> <enum>Qt::Horizontal</enum>
<item> </property>
<widget class="QCheckBox" name="checkTwoPasswordsAbort"> <property name="sizeType">
<property name="text"> <enum>QSizePolicy::Fixed</enum>
<string>2nd password abort</string> </property>
</property> <property name="sizeHint" stdset="0">
</widget> <size>
</item> <width>20</width>
</layout> <height>10</height>
</widget> </size>
</item> </property>
</layout> </spacer>
</item>
<item>
<widget class="QLineEdit" name="editUsernamePasswordError">
<property name="minimumSize">
<size>
<width>200</width>
<height>0</height>
</size>
</property>
<property name="text">
<string>Username/password error.</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QCheckBox" name="checkHV3Required">
<property name="text">
<string>HV3 required</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="checkHV3Error">
<property name="text">
<string>HV3 error</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="checkFreeUserError">
<property name="text">
<string>Free user error</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="checkTFARequired">
<property name="text">
<string>2FA required</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="checkTFAError">
<property name="text">
<string>2FA error</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="checkTFAAbort">
<property name="text">
<string>2FA abort</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="checkTwoPasswordsRequired">
<property name="text">
<string>2nd password required</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="checkTwoPasswordsError">
<property name="text">
<string>2nd password error</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="checkTwoPasswordsAbort">
<property name="text">
<string>2nd password abort</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</widget>
</item> </item>
</layout> </layout>
</widget> </widget>

View File

@ -75,7 +75,7 @@ if(NOT UNIX)
set(CMAKE_INSTALL_BINDIR ".") set(CMAKE_INSTALL_BINDIR ".")
endif(NOT UNIX) endif(NOT UNIX)
find_package(Qt6 COMPONENTS Core Quick Qml QuickControls2 Widgets Svg REQUIRED) find_package(Qt6 COMPONENTS Core Quick Qml QuickControls2 Widgets Svg Gui REQUIRED)
qt_standard_project_setup() qt_standard_project_setup()
set(CMAKE_AUTORCC ON) set(CMAKE_AUTORCC ON)
message(STATUS "Using Qt ${Qt6_VERSION}") message(STATUS "Using Qt ${Qt6_VERSION}")
@ -120,6 +120,7 @@ add_executable(bridge-gui
UserList.cpp UserList.h UserList.cpp UserList.h
SentryUtils.cpp SentryUtils.h SentryUtils.cpp SentryUtils.h
Settings.cpp Settings.h Settings.cpp Settings.h
ClipboardProxy.cpp ClipboardProxy.h
${DOCK_ICON_SRC_FILE} MacOS/DockIcon.h ${DOCK_ICON_SRC_FILE} MacOS/DockIcon.h
) )
@ -148,6 +149,7 @@ target_link_libraries(bridge-gui
Qt6::Qml Qt6::Qml
Qt6::QuickControls2 Qt6::QuickControls2
Qt6::Svg Qt6::Svg
Qt6::Gui
sentry::sentry sentry::sentry
bridgepp bridgepp
) )

View File

@ -0,0 +1,25 @@
// Copyright (c) 2024 Proton AG
// This file is part of Proton Mail Bridge.
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
#include "ClipboardProxy.h"
// The following definitions were taken and adapted from:
// https://stackoverflow.com/questions/40092352/passing-qclipboard-to-qml
// Author: krzaq
ClipboardProxy::ClipboardProxy(QClipboard* c) : clipboard(c) {
connect(clipboard, &QClipboard::dataChanged, this, &ClipboardProxy::textChanged);
}
QString ClipboardProxy::text() const {
return clipboard->text();
}

View File

@ -0,0 +1,38 @@
// Copyright (c) 2024 Proton AG
// This file is part of Proton Mail Bridge.
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
#ifndef BRIDGE_GUI_CLIPBOARDPROXY_H
#define BRIDGE_GUI_CLIPBOARDPROXY_H
// The following class declarations were taken and adapted from:
// https://stackoverflow.com/questions/40092352/passing-qclipboard-to-qml
// Author: krzaq
class ClipboardProxy : public QObject
{
Q_OBJECT
Q_PROPERTY(QString text READ text NOTIFY textChanged)
public:
explicit ClipboardProxy(QClipboard*);
QString text() const;
signals:
void textChanged();
private:
QClipboard* clipboard;
};
#endif //BRIDGE_GUI_CLIPBOARDPROXY_H

View File

@ -26,6 +26,7 @@
#include <QtWidgets> #include <QtWidgets>
#include <QtQuickControls2> #include <QtQuickControls2>
#include <QtSvg> #include <QtSvg>
#include <QtGui>
#include <AppController.h> #include <AppController.h>

View File

@ -1330,6 +1330,7 @@ void QMLBackend::connectGrpcEvents() {
connect(client, &GRPCClient::knowledgeBasSuggestionsReceived, this, &QMLBackend::receivedKnowledgeBaseSuggestions); connect(client, &GRPCClient::knowledgeBasSuggestionsReceived, this, &QMLBackend::receivedKnowledgeBaseSuggestions);
connect(client, &GRPCClient::repairStarted, this, &QMLBackend::repairStarted); connect(client, &GRPCClient::repairStarted, this, &QMLBackend::repairStarted);
connect(client, &GRPCClient::allUsersLoaded, this, &QMLBackend::allUsersLoaded); connect(client, &GRPCClient::allUsersLoaded, this, &QMLBackend::allUsersLoaded);
connect(client, &GRPCClient::userNotificationReceived, this, &QMLBackend::processUserNotification);
// cache events // cache events
connect(client, &GRPCClient::cantMoveDiskCache, this, &QMLBackend::cantMoveDiskCache); connect(client, &GRPCClient::cantMoveDiskCache, this, &QMLBackend::cantMoveDiskCache);
@ -1418,3 +1419,25 @@ void QMLBackend::triggerRepair() const {
app().grpc().triggerRepair(); app().grpc().triggerRepair();
) )
} }
//****************************************************************************************************************************************************
/// \param[in] notification The user notification received from the event loop.
//****************************************************************************************************************************************************
void QMLBackend::processUserNotification(bridgepp::UserNotification const& notification) {
this->userNotificationStack_.push(notification);
trayIcon_->showUserNotification(notification.title, notification.subtitle);
emit receivedUserNotification(notification);
}
void QMLBackend::userNotificationDismissed() {
if (!this->userNotificationStack_.size()) return;
// Remove the user notification from the top of the queue as it has been dismissed.
this->userNotificationStack_.pop();
if (!this->userNotificationStack_.size()) return;
// Display the user notification that is on top of the queue, if there is one.
auto notification = this->userNotificationStack_.top();
emit receivedUserNotification(notification);
}

View File

@ -28,6 +28,7 @@
#include <bridgepp/GRPC/GRPCClient.h> #include <bridgepp/GRPC/GRPCClient.h>
#include <bridgepp/GRPC/GRPCUtils.h> #include <bridgepp/GRPC/GRPCUtils.h>
#include <bridgepp/Worker/Overseer.h> #include <bridgepp/Worker/Overseer.h>
#include <stack>
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
@ -174,6 +175,8 @@ signals: // Signal used by the Qt property system. Many of them are unused but r
void isAutostartOnChanged(bool value); ///<Signal for the change of the 'isAutostartOn' property. void isAutostartOnChanged(bool value); ///<Signal for the change of the 'isAutostartOn' property.
void usersChanged(UserList *users); ///<Signal for the change of the 'users' property. void usersChanged(UserList *users); ///<Signal for the change of the 'users' property.
void dockIconVisibleChanged(bool value); ///<Signal for the change of the 'dockIconVisible' property. void dockIconVisibleChanged(bool value); ///<Signal for the change of the 'dockIconVisible' property.
void receivedUserNotification(bridgepp::UserNotification const& notification); ///< Signal to display the userNotification modal
public slots: // slot for signals received from QML -> To be forwarded to Bridge via RPC Client calls. public slots: // slot for signals received from QML -> To be forwarded to Bridge via RPC Client calls.
void toggleAutostart(bool active); ///< Slot for the autostart toggle. void toggleAutostart(bool active); ///< Slot for the autostart toggle.
@ -209,6 +212,7 @@ public slots: // slot for signals received from QML -> To be forwarded to Bridge
void notifyAutoconfigClicked(QString const &client) const; ///< Slot for gAutoconfigClicked gRPC event. void notifyAutoconfigClicked(QString const &client) const; ///< Slot for gAutoconfigClicked gRPC event.
void notifyExternalLinkClicked(QString const &article) const; ///< Slot for KBArticleClicked gRPC event. void notifyExternalLinkClicked(QString const &article) const; ///< Slot for KBArticleClicked gRPC event.
void triggerRepair() const; ///< Slot for the triggering of the bridge repair function i.e. 'resync'. void triggerRepair() const; ///< Slot for the triggering of the bridge repair function i.e. 'resync'.
void userNotificationDismissed(); ///< Slot to pop the notification from the stack and display the rest.
public slots: // slots for functions that need to be processed locally. public slots: // slots for functions that need to be processed locally.
void setNormalTrayIcon(); ///< Set the tray icon to normal. void setNormalTrayIcon(); ///< Set the tray icon to normal.
@ -224,6 +228,7 @@ public slots: // slot for signals received from gRPC that need transformation in
void onLoginAlreadyLoggedIn(QString const &userID); ///< Slot for the LoginAlreadyLoggedIn gRPC event. void onLoginAlreadyLoggedIn(QString const &userID); ///< Slot for the LoginAlreadyLoggedIn gRPC event.
void onUserBadEvent(QString const& userID, QString const& errorMessage); ///< Slot for the userBadEvent gRPC event. void onUserBadEvent(QString const& userID, QString const& errorMessage); ///< Slot for the userBadEvent gRPC event.
void onIMAPLoginFailed(QString const& username); ///< Slot the the imapLoginFailed event. void onIMAPLoginFailed(QString const& username); ///< Slot the the imapLoginFailed event.
void processUserNotification(bridgepp::UserNotification const& notification); ///< Slot for the userNotificationReceived gRCP event.
signals: // Signals received from the Go backend, to be forwarded to QML signals: // Signals received from the Go backend, to be forwarded to QML
void toggleAutostartFinished(); ///< Signal for the 'toggleAutostartFinished' gRPC stream event. void toggleAutostartFinished(); ///< Signal for the 'toggleAutostartFinished' gRPC stream event.
@ -310,6 +315,7 @@ private: // data members
QList<QString> badEventDisplayQueue_; ///< THe queue for displaying 'bad event feedback request dialog'. QList<QString> badEventDisplayQueue_; ///< THe queue for displaying 'bad event feedback request dialog'.
std::unique_ptr<TrayIcon> trayIcon_; ///< The tray icon for the application. std::unique_ptr<TrayIcon> trayIcon_; ///< The tray icon for the application.
bridgepp::BugReportFlow reportFlow_; ///< The bug report flow. bridgepp::BugReportFlow reportFlow_; ///< The bug report flow.
std::stack<bridgepp::UserNotification> userNotificationStack_; ///< The stack which holds all of the active notifications that the user needs to acknowledge.
friend class AppController; friend class AppController;
}; };

View File

@ -71,6 +71,7 @@
<file>qml/icons/systray-mono-update.png</file> <file>qml/icons/systray-mono-update.png</file>
<file>qml/icons/systray-mono-warn.png</file> <file>qml/icons/systray-mono-warn.png</file>
<file>qml/icons/systray.svg</file> <file>qml/icons/systray.svg</file>
<file>qml/icons/ic-notification-bell.svg</file>
<file alias="bridge.svg">../../../../dist/bridge.svg</file> <file alias="bridge.svg">../../../../dist/bridge.svg</file>
<file alias="bridgeMacOS.svg">../../../../dist/bridgeMacOS.svg</file> <file alias="bridgeMacOS.svg">../../../../dist/bridgeMacOS.svg</file>
<file>qml/KeychainSettings.qml</file> <file>qml/KeychainSettings.qml</file>
@ -78,6 +79,7 @@
<file>qml/MainWindow.qml</file> <file>qml/MainWindow.qml</file>
<file>qml/NoAccountView.qml</file> <file>qml/NoAccountView.qml</file>
<file>qml/NotificationDialog.qml</file> <file>qml/NotificationDialog.qml</file>
<file>qml/UserNotificationDialog.qml</file>
<file>qml/NotificationPopups.qml</file> <file>qml/NotificationPopups.qml</file>
<file>qml/Notifications/Notification.qml</file> <file>qml/Notifications/Notification.qml</file>
<file>qml/Notifications/NotificationFilter.qml</file> <file>qml/Notifications/NotificationFilter.qml</file>
@ -132,5 +134,6 @@
<file>qml/ConnectionModeSettings.qml</file> <file>qml/ConnectionModeSettings.qml</file>
<file>qml/SplashScreen.qml</file> <file>qml/SplashScreen.qml</file>
<file>qml/Status.qml</file> <file>qml/Status.qml</file>
<file>qml/Proton/ContextMenu.qml</file>
</qresource> </qresource>
</RCC> </RCC>

View File

@ -331,6 +331,15 @@ void TrayIcon::showErrorPopupNotification(QString const &title, QString const &m
this->showMessage(title, message, notificationErrorIcon_); this->showMessage(title, message, notificationErrorIcon_);
} }
//****************************************************************************************************************************************************
/// Used only by user notifications received from the event loop
/// \param[in] title The title.
/// \param[in] subtitle The subtitle.
//****************************************************************************************************************************************************
void TrayIcon::showUserNotification(QString const &title, QString const &subtitle) {
this->showMessage(title, subtitle, QSystemTrayIcon::NoIcon);
}
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
/// \param[in] svgPath The path of the SVG file for the icon. /// \param[in] svgPath The path of the SVG file for the icon.

View File

@ -42,6 +42,8 @@ public: // data members
TrayIcon& operator=(TrayIcon&&) = delete; ///< Disabled move assignment operator. TrayIcon& operator=(TrayIcon&&) = delete; ///< Disabled move assignment operator.
void setState(State state, QString const& stateString, QString const &statusIconPath); ///< Set the state of the icon void setState(State state, QString const& stateString, QString const &statusIconPath); ///< Set the state of the icon
void showErrorPopupNotification(QString const& title, QString const &message); ///< Display a pop up notification. void showErrorPopupNotification(QString const& title, QString const &message); ///< Display a pop up notification.
void showUserNotification(QString const& title, QString const &subtitle); ///< Display an OS pop up notification (without icon).
signals: signals:
void selectUser(QString const& userID, bool forceShowWindow); ///< Signal for selecting a user with a given userID void selectUser(QString const& userID, bool forceShowWindow); ///< Signal for selecting a user with a given userID

View File

@ -28,6 +28,7 @@
#include <bridgepp/Log/Log.h> #include <bridgepp/Log/Log.h>
#include <bridgepp/Log/LogUtils.h> #include <bridgepp/Log/LogUtils.h>
#include <bridgepp/ProcessMonitor.h> #include <bridgepp/ProcessMonitor.h>
#include <ClipboardProxy.h>
#include "bridgepp/CLI/CLIUtils.h" #include "bridgepp/CLI/CLIUtils.h"
@ -347,6 +348,8 @@ int main(int argc, char *argv[]) {
log.info(QString("Qt Quick renderer: %1").arg(QQuickWindow::sceneGraphBackend())); log.info(QString("Qt Quick renderer: %1").arg(QQuickWindow::sceneGraphBackend()));
QQmlApplicationEngine engine; QQmlApplicationEngine engine;
// Set up clipboard
engine.rootContext()->setContextProperty("clipboard", new ClipboardProxy(QGuiApplication::clipboard()));
std::unique_ptr<QQmlComponent> rootComponent(createRootQmlComponent(engine)); std::unique_ptr<QQmlComponent> rootComponent(createRootQmlComponent(engine));
std::unique_ptr<QObject> rootObject(rootComponent->create(engine.rootContext())); std::unique_ptr<QObject> rootObject(rootComponent->create(engine.rootContext()));
if (!rootObject) { if (!rootObject) {

View File

@ -22,6 +22,7 @@ Dialog {
default property alias data: additionalChildrenContainer.children default property alias data: additionalChildrenContainer.children
property var notification property var notification
property bool isUserNotification: false
modal: true modal: true
shouldShow: notification && notification.active && !notification.dismissed shouldShow: notification && notification.active && !notification.dismissed
@ -39,13 +40,13 @@ Dialog {
return ""; return "";
} }
switch (root.notification.type) { switch (root.notification.type) {
case Notification.NotificationType.Info: case Notification.NotificationType.Info:
return "/qml/icons/ic-info.svg"; return "/qml/icons/ic-info.svg";
case Notification.NotificationType.Success: case Notification.NotificationType.Success:
return "/qml/icons/ic-success.svg"; return "/qml/icons/ic-success.svg";
case Notification.NotificationType.Warning: case Notification.NotificationType.Warning:
case Notification.NotificationType.Danger: case Notification.NotificationType.Danger:
return "/qml/icons/ic-alert.svg"; return "/qml/icons/ic-alert.svg";
} }
} }
sourceSize.height: 64 sourceSize.height: 64

View File

@ -109,4 +109,8 @@ Item {
colorScheme: root.colorScheme colorScheme: root.colorScheme
notification: root.notifications.repairBridge notification: root.notifications.repairBridge
} }
UserNotificationDialog {
colorScheme: root.colorScheme
notification: root.notifications.userNotification
}
} }

View File

@ -19,7 +19,8 @@ QtObject {
Info, Info,
Success, Success,
Warning, Warning,
Danger Danger,
UserNotification
} }
property list<Action> action property list<Action> action
@ -36,6 +37,9 @@ QtObject {
readonly property var occurred: active ? new Date() : undefined readonly property var occurred: active ? new Date() : undefined
property string title // title is used in dialogs only property string title // title is used in dialogs only
property int type property int type
property string subtitle
property string username
onActiveChanged: { onActiveChanged: {
dismissed = false; dismissed = false;

View File

@ -62,7 +62,7 @@ QtObject {
target: Backend target: Backend
} }
} }
property var all: [root.noInternet, root.imapPortStartupError, root.smtpPortStartupError, root.imapPortChangeError, root.smtpPortChangeError, root.imapConnectionModeChangeError, root.smtpConnectionModeChangeError, root.updateManualReady, root.updateManualRestartNeeded, root.updateManualError, root.updateForce, root.updateForceError, root.updateSilentRestartNeeded, root.updateSilentError, root.updateIsLatestVersion, root.loginConnectionError, root.onlyPaidUsers, root.alreadyLoggedIn, root.enableBeta, root.bugReportSendSuccess, root.bugReportSendError, root.bugReportSendFallback, root.cacheCantMove, root.cacheLocationChangeSuccess, root.enableSplitMode, root.resetBridge, root.changeAllMailVisibility, root.deleteAccount, root.noKeychain, root.rebuildKeychain, root.addressChanged, root.apiCertIssue, root.userBadEvent, root.imapLoginWhileSignedOut, root.genericError, root.genericQuestion, root.hvErrorEvent, root.repairBridge] property var all: [root.noInternet, root.imapPortStartupError, root.smtpPortStartupError, root.imapPortChangeError, root.smtpPortChangeError, root.imapConnectionModeChangeError, root.smtpConnectionModeChangeError, root.updateManualReady, root.updateManualRestartNeeded, root.updateManualError, root.updateForce, root.updateForceError, root.updateSilentRestartNeeded, root.updateSilentError, root.updateIsLatestVersion, root.loginConnectionError, root.onlyPaidUsers, root.alreadyLoggedIn, root.enableBeta, root.bugReportSendSuccess, root.bugReportSendError, root.bugReportSendFallback, root.cacheCantMove, root.cacheLocationChangeSuccess, root.enableSplitMode, root.resetBridge, root.changeAllMailVisibility, root.deleteAccount, root.noKeychain, root.rebuildKeychain, root.addressChanged, root.apiCertIssue, root.userBadEvent, root.imapLoginWhileSignedOut, root.genericError, root.genericQuestion, root.hvErrorEvent, root.repairBridge, root.userNotification]
property Notification alreadyLoggedIn: Notification { property Notification alreadyLoggedIn: Notification {
brief: qsTr("Already signed in") brief: qsTr("Already signed in")
description: qsTr("This account is already signed in.") description: qsTr("This account is already signed in.")
@ -1187,7 +1187,7 @@ QtObject {
} }
target: root target: root
} }
Connections { Connections {
function onRepairStarted() { function onRepairStarted() {
root.repairBridge.active = false; root.repairBridge.active = false;
@ -1200,6 +1200,35 @@ QtObject {
} }
property Notification userNotification: Notification {
brief: title
group: Notifications.Group.Dialogs
type: Notification.NotificationType.UserNotification
icon: "./icons/ic-exclamation-circle-filled.svg" // If it's not included QML complains
action: [
Action {
text: qsTr("Okay")
onTriggered: {
root.userNotification.active = false;
Backend.userNotificationDismissed();
}
}
]
Connections {
function onReceivedUserNotification(notification) {
const userPrimaryEmailOrUsername = Backend.users.primaryEmailOrUsername(notification.userID)
root.userNotification.title = notification.title
root.userNotification.subtitle = notification.subtitle
root.userNotification.description = notification.body
root.userNotification.username = userPrimaryEmailOrUsername
root.userNotification.active = true
}
target: Backend
}
}
signal askChangeAllMailVisibility(var isVisibleNow) signal askChangeAllMailVisibility(var isVisibleNow)
signal askDeleteAccount(var user) signal askDeleteAccount(var user)
signal askEnableBeta signal askEnableBeta

View File

@ -73,6 +73,11 @@ T.ApplicationWindow {
if (obj.shouldShow === false) { if (obj.shouldShow === false) {
continue; continue;
} }
// User notifications should have display priority
if (obj.shouldShow && obj.isUserNotification) {
topmost = obj;
break;
}
if (topmost && (topmost.popupType > obj.popupType)) { if (topmost && (topmost.popupType > obj.popupType)) {
continue; continue;
} }

View File

@ -0,0 +1,79 @@
// Copyright (c) 2024 Proton AG
// This file is part of Proton Mail Bridge.
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Controls
Item {
property var parentObject: null
property var colorScheme: null
property bool readOnly: false
property bool isPassword: false
MouseArea {
id: controlMouseArea
width: parentObject ? parentObject.width : 0
height: parentObject ? parentObject.height : 0
acceptedButtons: Qt.RightButton
onClicked: controlContextMenu.popup()
propagateComposedEvents: true
}
Menu {
id: controlContextMenu
colorScheme: root.colorScheme
onVisibleChanged: {
if (controlContextMenu.visible) {
const hasSelectedText = parentObject.selectedText.length > 0;
const hasClipboardText = clipboard.text.length > 0;
copyMenuItem.visible = hasSelectedText && !isPassword;
cutMenuItem.visible = hasSelectedText && !readOnly && !isPassword;
pasteMenuItem.visible = hasClipboardText && !readOnly;
controlContextMenu.visible = copyMenuItem.visible || cutMenuItem.visible || pasteMenuItem.visible;
}
}
MenuItem {
id: cutMenuItem
colorScheme: root.colorScheme
height: visible ? implicitHeight : 0
text: qsTr("Cut")
onClicked: {
parentObject.cut()
}
}
MenuItem {
id: copyMenuItem
colorScheme: root.colorScheme
height: visible ? implicitHeight : 0
text: qsTr("Copy")
onTriggered: {
parentObject.copy()
}
}
MenuItem {
id: pasteMenuItem
colorScheme: root.colorScheme
height: visible ? implicitHeight : 0
text: qsTr("Paste")
onTriggered: {
parentObject.paste()
}
}
}
}

View File

@ -362,4 +362,9 @@ FocusScope {
} }
} }
} }
Proton.ContextMenu {
parentObject: root
colorScheme: root.colorScheme
}
} }

View File

@ -331,6 +331,15 @@ FocusScope {
x: control.leftPadding x: control.leftPadding
y: control.topPadding y: control.topPadding
} }
Proton.ContextMenu {
parentObject: control
colorScheme: root.colorScheme
isPassword: control.echoMode === TextInput.Password
readOnly: control.readOnly
}
} }
Proton.Button { Proton.Button {
id: eyeButton id: eyeButton

View File

@ -39,3 +39,4 @@ TextArea 4.0 TextArea.qml
TextField 4.0 TextField.qml TextField 4.0 TextField.qml
Toggle 4.0 Toggle.qml Toggle 4.0 Toggle.qml
WebFrame 4.0 WebFrame.qml WebFrame 4.0 WebFrame.qml
ContextMenu 4.0 ContextMenu.qml

View File

@ -0,0 +1,120 @@
// Copyright (c) 2024 Proton AG
// This file is part of Proton Mail Bridge.
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQml
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Proton
import Notifications
Dialog {
id: root
property var notification
property bool isUserNotification: true
padding: 40
modal: true
shouldShow: notification && notification.active && !notification.dismissed
ColumnLayout {
spacing: 0
Image {
Layout.alignment: Qt.AlignHCenter
Layout.bottomMargin: 16
Layout.preferredHeight: 64
Layout.preferredWidth: 64
source: {
if (!root.notification) {
return "";
}
switch (root.notification.type) {
case Notification.NotificationType.UserNotification:
return "/qml/icons/ic-notification-bell.svg"
}
}
sourceSize.height: 64
sourceSize.width: 64
visible: source != ""
}
// Title Label
Label {
Layout.alignment: Qt.AlignHCenter
Layout.bottomMargin: 4
Layout.preferredWidth: 320
colorScheme: root.colorScheme
horizontalAlignment: Text.AlignHCenter
text: root.notification.title
wrapMode: Text.WordWrap
type: Label.LabelType.Title
}
// Username or primary email
Label {
Layout.alignment: Qt.AlignHCenter
Layout.bottomMargin: 24
Layout.preferredWidth: 320
colorScheme: root.colorScheme
horizontalAlignment: Text.AlignHCenter
text: root.notification.username
wrapMode: Text.WordWrap
visible: root.notification.username.length > 0
type: Label.LabelType.Caption
}
// Subtitle
Label {
Layout.alignment: Qt.AlignHCenter
Layout.bottomMargin: 24
Layout.fillWidth: true
Layout.preferredWidth: 320
colorScheme: root.colorScheme
horizontalAlignment: Text.AlignHCenter
text: root.notification.subtitle
wrapMode: Text.WordWrap
visible: root.notification.subtitle.length > 0
type: Label.LabelType.Lead
color: root.colorScheme.text_weak
}
Label {
Layout.bottomMargin: 24
Layout.fillWidth: true
Layout.preferredWidth: 320
colorScheme: root.colorScheme
horizontalAlignment: Text.AlignHCenter
text: root.notification.description
type: Label.LabelType.Body
wrapMode: Text.WordWrap
onLinkActivated: function (link) {
Backend.openExternalLink(link);
}
}
ColumnLayout {
spacing: 40
Repeater {
model: root.notification.action
delegate: Button {
Layout.fillWidth: true
action: modelData
colorScheme: root.colorScheme
loading: modelData.loading
secondary: index > 0
}
}
}
}
}

View File

@ -0,0 +1,37 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="none" viewBox="0 0 64 64">
<g clip-path="url(#a)">
<circle cx="13.031" cy="5.288" r="3.166" fill="#2C83DC" transform="rotate(-30 13.031 5.288)"/>
<path fill="url(#b)" d="M3.599 27.28A19.757 19.757 0 0 1 36.793 8.115L53.92 25.808a12.42 12.42 0 0 0 4.581 2.998c3.454 1.288 5.556 4.214 3.201 7.05-2.76 3.325-8.795 8.475-21.796 15.981S19.428 61.994 15.169 62.723c-3.634.621-5.117-2.662-4.506-6.298a12.422 12.422 0 0 0-.306-5.466L3.6 27.28Z"/>
<path fill="url(#c)" d="M3.599 27.28A19.757 19.757 0 0 1 36.793 8.115L53.92 25.808a12.42 12.42 0 0 0 4.581 2.998c3.454 1.288 5.556 4.214 3.201 7.05-2.76 3.325-8.795 8.475-21.796 15.981S19.428 61.994 15.169 62.723c-3.634.621-5.117-2.662-4.506-6.298a12.422 12.422 0 0 0-.306-5.466L3.6 27.28Z"/>
<ellipse cx="37.094" cy="46.965" fill="url(#d)" rx="26.875" ry="3.75" transform="rotate(-30 37.094 46.965)"/>
<ellipse cx="37.094" cy="46.965" fill="url(#e)" rx="26.875" ry="3.75" transform="rotate(-30 37.094 46.965)"/>
<path fill="#fff" fill-opacity=".2" d="m48.156 19.855-2.032-2.1c-6.302 1.79-12.908 4.57-19.41 8.324-7.591 4.383-14.103 9.553-19.19 14.959l.914 3.201c4.88-5.52 11.835-11.152 20.16-15.958 6.703-3.87 13.425-6.704 19.558-8.426Z"/>
<circle cx="36.469" cy="45.883" r="2.5" fill="url(#f)" transform="rotate(-30 36.469 45.883)"/>
</g>
<defs>
<linearGradient id="d" x1="49.16" x2="37.094" y1="30.878" y2="49.439" gradientUnits="userSpaceOnUse">
<stop stop-color="#256097"/>
<stop offset="1" stop-color="#2C83DC"/>
</linearGradient>
<linearGradient id="e" x1="49.68" x2="49.037" y1="46.478" y2="51.592" gradientUnits="userSpaceOnUse">
<stop stop-color="#27ABF4" stop-opacity="0"/>
<stop offset="1" stop-color="#27ABF4" stop-opacity=".5"/>
</linearGradient>
<linearGradient id="f" x1="35.719" x2="36.469" y1="43.133" y2="48.383" gradientUnits="userSpaceOnUse">
<stop offset=".345" stop-color="#B2EAFE" stop-opacity="0"/>
<stop offset="1" stop-color="#B2EAFE"/>
</linearGradient>
<radialGradient id="b" cx="0" cy="0" r="1" gradientTransform="rotate(131.347 12.11 6.294) scale(52.6374 54.7116)" gradientUnits="userSpaceOnUse">
<stop stop-color="#B2EAFE"/>
<stop offset="1" stop-color="#27ABF4"/>
</radialGradient>
<radialGradient id="c" cx="0" cy="0" r="1" gradientTransform="rotate(60 -23.22 57.501) scale(21.25 76.0937)" gradientUnits="userSpaceOnUse">
<stop stop-color="#fff" stop-opacity="0"/>
<stop offset=".46" stop-color="#fff" stop-opacity=".4"/>
<stop offset=".58" stop-color="#B2EAFE" stop-opacity=".5"/>
</radialGradient>
<clipPath id="a">
<path fill="#fff" d="M0 0h64v64H0z"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -729,4 +729,23 @@ SPStreamEvent newGenericErrorEvent(grpc::ErrorCode errorCode) {
} }
//****************************************************************************************************************************************************
/// \param[in] userID The user ID that received the notification.
/// \param[in] title The title of the notification.
/// \param[in] subtitle The subtitle of the notification.
/// \param[in] body The body of the notification.
/// \return The event.
//****************************************************************************************************************************************************
SPStreamEvent newUserNotificationEvent(QString const &userID, QString const title, QString const subtitle, QString const body) {
auto event = new grpc::UserNotificationEvent;
event->set_userid(userID.toStdString());
event->set_body(body.toStdString());
event->set_subtitle(subtitle.toStdString());
event->set_title(title.toStdString());
auto appEvent = new grpc::AppEvent;
appEvent->set_allocated_usernotification(event);
return wrapAppEvent(appEvent);
}
} // namespace bridgepp } // namespace bridgepp

View File

@ -94,6 +94,9 @@ SPStreamEvent newSyncProgressEvent(QString const &userID, double progress, qint6
// Generic error event // Generic error event
SPStreamEvent newGenericErrorEvent(grpc::ErrorCode errorCode); ///< Create a new GenericErrrorEvent event. SPStreamEvent newGenericErrorEvent(grpc::ErrorCode errorCode); ///< Create a new GenericErrrorEvent event.
// User notification event
SPStreamEvent newUserNotificationEvent(QString const &userID, QString const title, QString const subtitle, QString const body);
} // namespace bridgepp } // namespace bridgepp

View File

@ -1206,6 +1206,17 @@ void GRPCClient::processAppEvent(AppEvent const &event) {
this->logTrace("App event received: AllUsersLoaded"); this->logTrace("App event received: AllUsersLoaded");
emit allUsersLoaded(); emit allUsersLoaded();
break; break;
case AppEvent::kUserNotification: {
this->logTrace("App event received: UserNotification");
UserNotification notification{
.title = QString::fromStdString(event.usernotification().title()),
.subtitle = QString::fromStdString(event.usernotification().subtitle()),
.body = QString::fromStdString(event.usernotification().body()),
.userID = QString::fromStdString(event.usernotification().userid()),
};
emit userNotificationReceived(notification);
break;
}
default: default:
this->logError("Unknown App event received."); this->logError("Unknown App event received.");
} }

View File

@ -56,6 +56,24 @@ public:
}; };
//****************************************************************************************************************************************************
/// \brief A struct for user notitifications.
//****************************************************************************************************************************************************
struct UserNotification {
// The following lines make the type transmissible to QML (but not instanciable there)
Q_GADGET
Q_PROPERTY(QString title MEMBER title)
Q_PROPERTY(QString subtitle MEMBER subtitle)
Q_PROPERTY(QString body MEMBER body)
Q_PROPERTY(QString userID MEMBER userID)
public:
QString title; ///< The title of the notification.
QString subtitle; ///< The subtitle of the notification.
QString body; ///< The body of the notification.
QString userID; ///< The userID that received the notification.
};
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
/// \brief gRPC client class. This class encapsulate the gRPC service, abstracting all data type conversions. /// \brief gRPC client class. This class encapsulate the gRPC service, abstracting all data type conversions.
//**************************************************************************************************************************************************** //****************************************************************************************************************************************************
@ -125,6 +143,7 @@ signals: // app related signals
void knowledgeBasSuggestionsReceived(QList<KnowledgeBaseSuggestion> const& suggestions); void knowledgeBasSuggestionsReceived(QList<KnowledgeBaseSuggestion> const& suggestions);
void repairStarted(); void repairStarted();
void allUsersLoaded(); void allUsersLoaded();
void userNotificationReceived(UserNotification const& notification);
public: // cache related calls public: // cache related calls

View File

@ -20,6 +20,7 @@ package cli
import ( import (
"errors" "errors"
"fmt"
"os" "os"
"runtime" "runtime"
@ -500,6 +501,18 @@ func (f *frontendCLI) watchEvents(eventCh <-chan events.Event) { // nolint:gocyc
case events.Raise: case events.Raise:
f.Printf("Hello!") f.Printf("Hello!")
case events.UserNotification:
user, err := f.bridge.GetUserInfo(event.UserID)
if err != nil {
return
}
fmt.Printf("\n--- NOTIFICATION ---\n\n")
fmt.Printf("Sent to: %s\n", user.Username)
fmt.Printf("Title: %s\n", event.Title)
fmt.Printf("Subtitle: %s\n", event.Subtitle)
fmt.Printf("Message: %s\n\n", event.Body)
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -277,6 +277,7 @@ message AppEvent {
KnowledgeBaseSuggestionsEvent knowledgeBaseSuggestions = 12; KnowledgeBaseSuggestionsEvent knowledgeBaseSuggestions = 12;
RepairStartedEvent repairStarted = 13; RepairStartedEvent repairStarted = 13;
AllUsersLoadedEvent allUsersLoaded = 14; AllUsersLoadedEvent allUsersLoaded = 14;
UserNotificationEvent userNotification = 15;
} }
} }
@ -545,6 +546,14 @@ message SyncProgressEvent {
int64 remainingMs = 4; int64 remainingMs = 4;
} }
message UserNotificationEvent {
string title = 1;
string subtitle = 2;
string body = 3;
string userID = 4;
}
//********************************************************** //**********************************************************
// Generic errors // Generic errors
//********************************************************** //**********************************************************

View File

@ -18,6 +18,7 @@
package grpc package grpc
import ( import (
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/kb" "github.com/ProtonMail/proton-bridge/v3/internal/kb"
"github.com/bradenaw/juniper/xslices" "github.com/bradenaw/juniper/xslices"
) )
@ -249,6 +250,16 @@ func NewAllUsersLoadedEvent() *StreamEvent {
return appEvent(&AppEvent{Event: &AppEvent_AllUsersLoaded{AllUsersLoaded: &AllUsersLoadedEvent{}}}) return appEvent(&AppEvent{Event: &AppEvent_AllUsersLoaded{AllUsersLoaded: &AllUsersLoadedEvent{}}})
} }
func NewUserNotificationEvent(event events.UserNotification) *StreamEvent {
return appEvent(&AppEvent{Event: &AppEvent_UserNotification{
UserNotification: &UserNotificationEvent{
UserID: event.UserID,
Title: event.Title,
Subtitle: event.Subtitle,
Body: event.Body,
}}})
}
// Event category factory functions. // Event category factory functions.
func appEvent(appEvent *AppEvent) *StreamEvent { func appEvent(appEvent *AppEvent) *StreamEvent {

View File

@ -404,6 +404,9 @@ func (s *Service) watchEvents() {
case events.AllUsersLoaded: case events.AllUsersLoaded:
_ = s.SendEvent(NewAllUsersLoadedEvent()) _ = s.SendEvent(NewAllUsersLoadedEvent())
case events.UserNotification:
_ = s.SendEvent(NewUserNotificationEvent(event))
} }
} }
} }
@ -581,7 +584,7 @@ func validateServerToken(ctx context.Context, wantToken string) error {
// newUnaryTokenValidator checks the server token for every unary gRPC call. // newUnaryTokenValidator checks the server token for every unary gRPC call.
func newUnaryTokenValidator(wantToken string) grpc.UnaryServerInterceptor { func newUnaryTokenValidator(wantToken string) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { return func(ctx context.Context, req interface{}, _ *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
if err := validateServerToken(ctx, wantToken); err != nil { if err := validateServerToken(ctx, wantToken); err != nil {
return nil, err return nil, err
} }
@ -592,7 +595,7 @@ func newUnaryTokenValidator(wantToken string) grpc.UnaryServerInterceptor {
// newStreamTokenValidator checks the server token for every gRPC stream request. // newStreamTokenValidator checks the server token for every gRPC stream request.
func newStreamTokenValidator(wantToken string) grpc.StreamServerInterceptor { func newStreamTokenValidator(wantToken string) grpc.StreamServerInterceptor {
return func(srv interface{}, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { return func(srv interface{}, stream grpc.ServerStream, _ *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
if err := validateServerToken(stream.Context(), wantToken); err != nil { if err := validateServerToken(stream.Context(), wantToken); err != nil {
return err return err
} }
@ -626,9 +629,7 @@ func (s *Service) monitorParentPID() {
go func() { go func() {
defer async.HandlePanic(s.panicHandler) defer async.HandlePanic(s.panicHandler)
if err := s.quit(); err != nil { s.quit()
logrus.WithError(err).Error("Error on quit")
}
}() }()
} }

View File

@ -110,10 +110,11 @@ func (s *Service) GuiReady(_ context.Context, _ *emptypb.Empty) (*GuiReadyRespon
func (s *Service) Quit(_ context.Context, _ *emptypb.Empty) (*emptypb.Empty, error) { func (s *Service) Quit(_ context.Context, _ *emptypb.Empty) (*emptypb.Empty, error) {
defer async.HandlePanic(s.panicHandler) defer async.HandlePanic(s.panicHandler)
s.log.Debug("Quit") s.log.Debug("Quit")
return &emptypb.Empty{}, s.quit() s.quit()
return &emptypb.Empty{}, nil
} }
func (s *Service) quit() error { func (s *Service) quit() {
// Windows is notably slow at Quitting. We do it in a goroutine to speed things up a bit. // Windows is notably slow at Quitting. We do it in a goroutine to speed things up a bit.
go func() { go func() {
defer async.HandlePanic(s.panicHandler) defer async.HandlePanic(s.panicHandler)
@ -132,8 +133,6 @@ func (s *Service) quit() error {
// The following call is launched as a goroutine, as it will wait for current calls to end, including this one. // The following call is launched as a goroutine, as it will wait for current calls to end, including this one.
s.grpcServer.GracefulStop() // gRPC does clean up and remove the file socket if used. s.grpcServer.GracefulStop() // gRPC does clean up and remove the file socket if used.
}() }()
return nil
} }
// Restart implement the Restart gRPC service call. // Restart implement the Restart gRPC service call.

View File

@ -76,7 +76,8 @@ func (s *Service) RunEventStream(request *EventStreamRequest, server Bridge_RunE
} }
case <-server.Context().Done(): case <-server.Context().Done():
s.log.Debug("Client closed the stream, exiting") s.log.Debug("Client closed the stream, exiting")
return s.quit() s.quit()
return nil
} }
} }
} }

View File

@ -4,46 +4,45 @@
"url": "https://proton.me/support/automatically-start-bridge", "url": "https://proton.me/support/automatically-start-bridge",
"title": "Automatically start Bridge", "title": "Automatically start Bridge",
"keywords": [ "keywords": [
"automatic", "autostart",
"login", "login",
"start", "startup",
"boot" "boot"
] ]
}, },
{ {
"index": 1, "index": 1,
"url": "https://proton.me/support/bridge-automatic-update", "url": "https://proton.me/support/proton-mail-bridge-new-outlook-for-windows-set-up-guide",
"title": "Automatic Update and Bridge", "title": "Proton Mail Bridge New Outlook for Windows set up guide",
"keywords": [ "keywords": [
"update", "app password",
"upgrade", "INVALIDCREDENTIALS",
"restart", "TEMPORARILYUNAVAILABLE",
"automatic", "New Outlook",
"manual" "Outlook",
"Sign in failed"
] ]
}, },
{ {
"index": 2, "index": 2,
"url": "https://proton.me/support/messages-encrypted-via-bridge", "url": "https://proton.me/support/what-is-the-recovered-messages-folder-in-bridge",
"title": "Are my messages encrypted via Proton Mail Bridge?", "title": "What is the Recovered Messages folder in Bridge (and your email client)?",
"keywords": [ "keywords": [
"encrypted", "recovered messages",
"privacy", "recovered messages folder"
"message",
"security",
"gpg",
"pgp",
"crypto"
] ]
}, },
{ {
"index": 3, "index": 3,
"url": "https://proton.me/support/labels-in-bridge", "url": "https://proton.me/support/protonmail-bridge-clients-macos-new-outlook#may-17",
"title": "Labels in Bridge", "title": "Important notice regarding the New Outlook for Mac and issues you might face",
"keywords": [ "keywords": [
"labels", "Receiving",
"folders", "Sending",
"directories" "Outlook",
"Configuration",
"Sync",
"New Outlook"
] ]
}, },
{ {
@ -51,14 +50,15 @@
"url": "https://proton.me/support/bridge-ssl-connection-issue", "url": "https://proton.me/support/bridge-ssl-connection-issue",
"title": "Proton Mail Bridge connection issues with Thunderbird, Outlook, and Apple Mail", "title": "Proton Mail Bridge connection issues with Thunderbird, Outlook, and Apple Mail",
"keywords": [ "keywords": [
"setup",
"disconnected",
"connect", "connect",
"SSL",
"STARTTLS",
"client",
"program",
"Outlook", "Outlook",
"Apple Mail", "Apple Mail",
"Thunderbird" "Thunderbird",
"Mac Mail",
"Could not connect to server",
"The attempt to read data from the server “127.0.0.1"
] ]
}, },
{ {
@ -81,7 +81,6 @@
"split", "split",
"address", "address",
"mode" "mode"
] ]
}, },
{ {
@ -91,7 +90,7 @@
"keywords": [ "keywords": [
"Thunderbird", "Thunderbird",
"Connection to server timed out", "Connection to server timed out",
"Connection", "Connection timed out",
"Timeout" "Timeout"
] ]
}, },
@ -110,7 +109,7 @@
{ {
"index": 9, "index": 9,
"url": "https://proton.me/support/port-already-occupied-error", "url": "https://proton.me/support/port-already-occupied-error",
"title": "Port already occupied error", "title": "How to fix “IMAP or SMTP port error” on Proton Mail Bridge",
"keywords": [ "keywords": [
"Port", "Port",
"occupied", "occupied",
@ -123,256 +122,28 @@
}, },
{ {
"index": 10, "index": 10,
"url": "https://proton.me/support/clients-supported-bridge", "url": "https://proton.me/support/apple-mail-certificate",
"title": "Email clients supported by Proton Mail Bridge", "title": "Why you need to install a certificate for Apple Mail with Proton Mail Bridge",
"keywords": [ "keywords": [
"client", "certificate",
"Outlook",
"Thunderbird",
"Apple Mail", "Apple Mail",
"EM Client", "mac",
"The Bat", "unsigned"
"Eudora",
"Postbox",
"Canary",
"Spark"
] ]
}, },
{ {
"index": 11, "index": 11,
"url": "https://proton.me/support/imap-smtp-and-pop3-setup", "url": "https://proton.me/support/macos-certificate-warning",
"title": "IMAP, SMTP, and POP3 setup", "title": "Warning when installing Proton Mail Bridge on macOS",
"keywords": [ "keywords": [
"IMAP", "install",
"SMTP", "mac",
"setup", "warning",
"set up", "certificate"
"configure",
"configuration",
"parameters"
] ]
}, },
{ {
"index": 12, "index": 12,
"url": "https://proton.me/support/protonmail-bridge-install",
"title": "How to install Proton Mail Bridge",
"keywords": [
"install",
"installer",
"setup",
"download",
"windows",
"mac",
"macos",
"linux"
]
},
{
"index": 13,
"url": "https://proton.me/support/bridge-for-linux",
"title": "Proton Mail Bridge for Linux",
"keywords": [
"Linux",
"Ubuntu",
"Fedora",
"Debian",
"Unix",
"deb",
"rpm",
"CentOS",
"Arch",
"Mint"
]
},
{
"index": 14,
"url": "https://proton.me/support/operating-systems-supported-bridge",
"title": "System requirements for Proton Mail Bridge",
"keywords": [
"requirement",
"cpu",
"memory",
"Windows",
"macOS",
"linux"
]
},
{
"index": 15,
"url": "https://proton.me/support/protonmail-bridge-configure-client",
"title": "How to configure your email client for Proton Mail Bridge",
"keywords": [
"Client",
"Outlook",
"configure",
"setup",
"application",
"setup",
"IMAP",
"SMTP"
]
},
{
"index": 16,
"url": "https://proton.me/support/invalid-password-error-setting-email-client",
"title": "Invalid password error while setting up email client",
"keywords": [
"password",
"invalid",
"error"
]
},
{
"index": 17,
"url": "https://proton.me/support/protonmail-bridge-clients-windows-outlook-2019",
"title": "Proton Mail Bridge Microsoft Outlook for Windows 2019 setup guide",
"keywords": [
"Outlook",
"2019",
"setup",
"configuration"
]
},
{
"index": 18,
"url": "https://proton.me/support/protonmail-bridge-clients-windows-outlook-2016",
"title": "Proton Mail Bridge Microsoft Outlook 2016 for Windows setup guide",
"keywords": [
"Outlook",
"2019",
"setup",
"configuration"
]
},
{
"index": 19,
"url": "https://proton.me/support/protonmail-bridge-clients-apple-mail",
"title": "Proton Mail Bridge Apple Mail setup guide",
"keywords": [
"Apple Mail",
"setup",
"configuration"
]
},
{
"index": 20,
"url": "https://proton.me/support/protonmail-bridge-clients-macos-new-outlook",
"title": "Proton Mail Bridge new Outlook for macOS setup guide",
"keywords": [
"Outlook",
"setup",
"configuration",
"We encountered an error while adding account"
]
},
{
"index": 21,
"url": "https://proton.me/support/protonmail-bridge-clients-windows-thunderbird",
"title": "Proton Mail Bridge Thunderbird setup guide for Windows, macOS, and Linux",
"keywords": [
"Thunderbird",
"setup",
"configuration"
]
},
{
"index": 22,
"url": "https://proton.me/support/protonmail-bridge-clients-macos-outlook-2016",
"title": "Proton Mail Bridge Microsoft Outlook 2016 for macOS setup guide",
"keywords": [
"Outlook 2016",
"macOS"
]
},
{
"index": 23,
"url": "https://proton.me/support/protonmail-bridge-clients-macos-outlook-2019",
"title": "Proton Mail Bridge Microsoft Outlook 2019 for macOS setup guide",
"keywords": [
"Outlook 2019",
"macOS"
]
},
{
"index": 24,
"url": "https://proton.me/support/protonmail-bridge-clients-windows-outlook-2013",
"title": "Proton Mail Bridge Microsoft Outlook 2013 for Windows setup guide",
"keywords": [
"Outlook 2013",
"macOS"
]
},
{
"index": 25,
"url": "https://proton.me/support/protonmail-bridge-clients-macos-outlook-2011",
"title": "Proton Mail Bridge Microsoft Outlook 2011 for macOS setup guide",
"keywords": [
"Outlook 2011",
"macOS"
]
},
{
"index": 26,
"url": "https://proton.me/support/install-bridge-linux-pkgbuild-file",
"title": "Installing Proton Mail Bridge for Linux using a PKGBUILD file",
"keywords": [
"Linux",
"pkgbuild"
]
},
{
"index": 27,
"url": "https://proton.me/support/installing-bridge-linux-deb-file",
"title": "Installing Proton Mail Bridge for Linux using a DEB file",
"keywords": [
"Linux",
"deb"
]
},
{
"index": 28,
"url": "https://proton.me/support/verifying-bridge-package",
"title": "Verifying the Proton Mail Bridge package for Linux",
"keywords": [
"Linux",
"Package",
"Verify"
]
},
{
"index": 29,
"url": "https://proton.me/support/bridge-cli-guide",
"title": "Bridge CLI (command line interface) guide",
"keywords": [
"CLI",
"Terminal",
"Command-line",
"Powershell"
]
},
{
"index": 30,
"url": "https://proton.me/support/install-bridge-linux-rpm-file",
"title": "Installing Proton Mail Bridge for Linux using an RPM file",
"keywords": [
"Linux",
"Install",
"RPM"
]
},
{
"index": 31,
"url": "https://proton.me/support/bridge-linux-login-error",
"title": "How to fix Proton Bridge login errors",
"keywords": [
"login",
"error",
"connection"
]
},
{
"index": 32,
"url": "https://proton.me/support/bridge-linux-tray-icon", "url": "https://proton.me/support/bridge-linux-tray-icon",
"title": "How to fix a missing system tray icon in Linux", "title": "How to fix a missing system tray icon in Linux",
"keywords": [ "keywords": [
@ -382,69 +153,228 @@
] ]
}, },
{ {
"index": 33, "index": 13,
"url": "https://proton.me/support/why-you-need-bridge", "url": "https://proton.me/support/bridge-linux-login-error",
"title": "Why you need Proton Mail Bridge", "title": "How to fix Proton Bridge login errors",
"keywords": [ "keywords": [
"Bridge", "login",
"email", "error",
"client" "sign in"
] ]
}, },
{ {
"index": 34, "index": 14,
"url": "https://proton.me/support/protonmail-bridge-manual-update", "url": "https://proton.me/support/protonmail-bridge-clients-macos-outlook-2019",
"title": "How to manually update Proton Mail Bridge", "title": "Proton Mail Bridge Microsoft Outlook 2019 for macOS setup guide",
"keywords": [ "keywords": [
"update",
"upgrade",
"manual",
"download"
]
},
{
"index": 35,
"url": "https://proton.me/support/macos-certificate-warning",
"title": "Warning when installing Proton Mail Bridge on macOS",
"keywords": [
"install",
"macOS",
"warning",
"certificate"
]
},
{
"index": 36,
"url": "https://proton.me/support/apple-mail-certificate",
"title": "Why you need to install a certificate for Apple Mail with Proton Mail Bridge",
"keywords": [
"certificate",
"Apple Mail",
"macOS"
]
},
{
"index": 37,
"url": "https://proton.me/support/protonmail-bridge-clients-macos-new-outlook#may-17",
"title": "Important notice regarding the New Outlook for Mac and issues you might face",
"keywords": [
"Receiving",
"Sending",
"Outlook", "Outlook",
"Configuration", "mac"
"Sync"
] ]
}, },
{ {
"index": 38, "index": 15,
"url": "https://proton.me/support/proton-mail-bridge-new-outlook-for-windows-set-up-guide", "url": "https://proton.me/support/protonmail-bridge-configure-client",
"title": "Proton Mail Bridge New Outlook for Windows set up guide", "title": "How to configure your email client for Proton Mail Bridge",
"keywords": [
"Client",
"configure",
"configuration",
"setup",
"application",
"setup"
]
},
{
"index": 16,
"url": "https://proton.me/support/invalid-password-error-setting-email-client",
"title": "Invalid password error while setting up email client",
"keywords": [
"password",
"invalid",
"error",
"incorrect",
"verify account name",
"login"
]
},
{
"index": 17,
"url": "https://proton.me/support/protonmail-bridge-clients-windows-outlook-2019",
"title": "Proton Mail Bridge Microsoft Outlook for Windows 2019 setup guide",
"keywords": [
"Outlook",
"setup",
"configure",
"connect",
"Outlook 365"
]
},
{
"index": 18,
"url": "https://proton.me/support/protonmail-bridge-clients-windows-thunderbird",
"title": "Proton Mail Bridge Thunderbird setup guide for Windows, macOS, and Linux",
"keywords": [
"Thunderbird",
"setup",
"configuration",
"connect"
]
},
{
"index": 19,
"url": "https://proton.me/support/protonmail-bridge-clients-apple-mail",
"title": "Proton Mail Bridge Apple Mail setup guide",
"keywords": [
"Apple Mail",
"setup",
"configure",
"configuration",
"connect"
]
},
{
"index": 20,
"url": "https://proton.me/support/protonmail-bridge-clients-macos-new-outlook",
"title": "Proton Mail Bridge new Outlook for macOS setup guide",
"keywords": [ "keywords": [
"Outlook", "Outlook",
"New Outlook", "New Outlook",
"Configuration", "setup",
"Configure", "configuration",
"Setup" "We encountered an error while adding the account",
"error",
"mac"
] ]
} },
{
"index": 21,
"url": "https://proton.me/support/how-to-resolve-connection-issues-in-bridge",
"title": "How to resolve connection issues in Bridge",
"keywords": [
"Bridge is not able to contact the server",
"connection",
"error",
"red banner",
"No Connection",
"issue",
"lost connection"
]
},
{
"index": 22,
"url": "https://proton.me/support/bridge-internal-error",
"title": "How to fix an “internal error” warning on Proton Mail Bridge",
"keywords": [
"internal error"
]
},
{
"index": 23,
"url": "https://proton.me/support/bridge-cant-move-cache",
"title": "How to fix a “cant move cache” error on Proton Mail Bridge",
"keywords": [
"Cant move cache",
"local cache"
]
},
{
"index": 24,
"url": "https://proton.me/support/how-to-troubleshoot-messages-received-with-a-delay-in-your-email-client",
"title": "How to troubleshoot messages received with a delay in your email client",
"keywords": [
"messages arriving slowly",
"New emails are not arriving",
"Emails arrive with a delay",
"messages not appearing",
"emails not appearing",
"I cannot find emails in my email client"
]
},
{
"index": 25,
"url": "https://proton.me/support/not-receiving-messages-email-client",
"title": "How to troubleshoot not receiving new messages in your email client",
"keywords": [
"emails are not arriving",
"emails missing",
"missing emails",
"emails not appearing",
"I cannot find emails in my email client"
]
},
{
"index": 26,
"url": "https://proton.me/support/resolve-invalid-return-path-error",
"title": "How to resolve the “Invalid Return Path” error when sending messages from your email client",
"keywords": [
"Invalid return path",
"I am not able to send emails",
"sending error",
"unable to send",
"send"
]
},
{
"index": 27,
"url": "https://proton.me/support/this-computer-only-folder-outlook",
"title": "How to troubleshoot “This computer only” folders in Outlook for Windows",
"keywords": [
"This computer only"
]
},
{
"index": 28,
"url": "https://proton.me/support/missing-messages-in-email-client-using-proton-bridge",
"title": "How to troubleshoot missing messages in your email client",
"keywords": [
"emails are missing",
"missing emails"
]
},
{
"index": 29,
"url": "https://proton.me/support/bridge-cannot-access-keychain",
"title": "How to fix “cannot access keychain” on Proton Mail Bridge",
"keywords": [
"cannot access keychain",
"No keychain available"
]
},
{
"index": 30,
"url": "https://proton.me/support/bridge-address-list-has-changed",
"title": "How to fix “the address list has changed” warning on Proton Mail Bridge",
"keywords": [
"address list has changed",
"address list changed"
]
},
{
"index": 31,
"url": "https://proton.me/support/failed-to-parse-message-error",
"title": "Troubleshooting “failed to parse message” errors",
"keywords": [
"failed to parse message"
]
},
{
"index": 32,
"url": "https://proton.me/support/bridge-imap-login-failed",
"title": "How to fix an “IMAP Login failed” warning on Proton Mail Bridge",
"keywords": [
"imap login failed",
"login failed"
]
},
{
"index": 33,
"url": "https://proton.me/support/resolve-checking-server-notice",
"title": "How to resolve issues with “Checking mail server capabilities” notice in Thunderbird",
"keywords": [
"Checking mail server capabilities",
"checking capabilities",
"checking",
"capabilities"
]
}
] ]

View File

@ -69,7 +69,11 @@ func GetSuggestionsFromArticleList(userInput string, articles ArticleList) (Arti
for _, article := range articles { for _, article := range articles {
for _, keyword := range article.Keywords { for _, keyword := range article.Keywords {
if strings.Contains(userInput, strings.ToUpper(keyword)) { if strings.Contains(userInput, strings.ToUpper(keyword)) {
article.Score++ if len(keyword) > 12 {
article.Score += 2
} else {
article.Score++
}
} }
} }
} }

View File

@ -42,7 +42,7 @@ func Test_GetSuggestions(t *testing.T) {
suggestions, err := GetSuggestions("Thunderbird is not working, error during password") suggestions, err := GetSuggestions("Thunderbird is not working, error during password")
require.NoError(t, err) require.NoError(t, err)
count := len(suggestions) count := len(suggestions)
require.True(t, (count > 0) && (count <= 3)) require.True(t, (count > 0) && (count <= 5))
suggestions, err = GetSuggestions("Supercalifragilisticexpialidocious Sesquipedalian Worcestershire") suggestions, err = GetSuggestions("Supercalifragilisticexpialidocious Sesquipedalian Worcestershire")
require.NoError(t, err) require.NoError(t, err)
require.Empty(t, suggestions) require.Empty(t, suggestions)
@ -75,10 +75,10 @@ func Test_GetSuggestionsFromArticleList(t *testing.T) {
} }
func Test_GetArticleIndex(t *testing.T) { func Test_GetArticleIndex(t *testing.T) {
index1, err := GetArticleIndex("https://proton.me/support/bridge-for-linux") index1, err := GetArticleIndex("https://proton.me/support/apple-mail-certificate")
require.NoError(t, err) require.NoError(t, err)
index2, err := GetArticleIndex("HTTPS://PROTON.ME/support/bridge-for-linux") index2, err := GetArticleIndex("HTTPS://PROTON.ME/support/apple-mail-certificate")
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, index1, index2) require.Equal(t, index1, index2)

View File

@ -78,7 +78,7 @@ func FuzzUnmarshal(f *testing.F) {
str := strings.Join(items, sep) str := strings.Join(items, sep)
f.Add([]byte(str)) f.Add([]byte(str))
f.Fuzz(func(t *testing.T, secret []byte) { f.Fuzz(func(_ *testing.T, secret []byte) {
encodedSecret := base64.StdEncoding.EncodeToString(secret) encodedSecret := base64.StdEncoding.EncodeToString(secret)
creds := &Credentials{} creds := &Credentials{}

View File

@ -206,6 +206,16 @@ func (l *Locations) ProvideIMAPSyncConfigPath() (string, error) {
return l.getIMAPSyncConfigPath(), nil return l.getIMAPSyncConfigPath(), nil
} }
// ProvideUnleashCachePath returns a location for the unleash cache data (e.g. ~/.cache/protonmail/bridge-v3).
// It creates it if it doesn't already exist.
func (l *Locations) ProvideUnleashCachePath() (string, error) {
if err := os.MkdirAll(l.getUnleashCachePath(), 0o700); err != nil {
return "", err
}
return l.getUnleashCachePath(), nil
}
func (l *Locations) getGluonCachePath() string { func (l *Locations) getGluonCachePath() string {
return filepath.Join(l.userData, "gluon") return filepath.Join(l.userData, "gluon")
} }
@ -238,10 +248,16 @@ func (l *Locations) getUpdatesPath() string {
return filepath.Join(l.userData, "updates") return filepath.Join(l.userData, "updates")
} }
func (l *Locations) getNotificationsCachePath() string {
return filepath.Join(l.userCache, "notifications")
}
func (l *Locations) getStatsPath() string { func (l *Locations) getStatsPath() string {
return filepath.Join(l.userData, "stats") return filepath.Join(l.userData, "stats")
} }
func (l *Locations) getUnleashCachePath() string { return filepath.Join(l.userCache, "unleash_cache") }
// Clear removes everything except the lock and update files. // Clear removes everything except the lock and update files.
func (l *Locations) Clear(except ...string) error { func (l *Locations) Clear(except ...string) error {
return files.Remove( return files.Remove(
@ -264,3 +280,13 @@ func (l *Locations) ClearUpdates() error {
func (l *Locations) CleanGoIMAPCache() error { func (l *Locations) CleanGoIMAPCache() error {
return files.Remove(l.getGoIMAPCachePath()).Do() return files.Remove(l.getGoIMAPCachePath()).Do()
} }
// ProvideNotificationsCachePath returns a location for notification deduplication data.
// It creates it if it doesn't already exist.
func (l *Locations) ProvideNotificationsCachePath() (string, error) {
if err := os.MkdirAll(l.getNotificationsCachePath(), 0o700); err != nil {
return "", err
}
return l.getNotificationsCachePath(), nil
}

View File

@ -172,7 +172,7 @@ func benchRotate(b *testing.B, logSize int64, getFile func(index int) (io.WriteC
} }
func getTestFile(b *testing.B, dir string, length int) func(int) (io.WriteCloser, error) { func getTestFile(b *testing.B, dir string, length int) func(int) (io.WriteCloser, error) {
return func(index int) (io.WriteCloser, error) { return func(_ int) (io.WriteCloser, error) {
b.StopTimer() b.StopTimer()
defer b.StartTimer() defer b.StartTimer()

View File

@ -19,4 +19,5 @@ package service
type Locator interface { type Locator interface {
ProvideSettingsPath() (string, error) ProvideSettingsPath() (string, error)
ProvideUnleashCachePath() (string, error)
} }

View File

@ -23,7 +23,6 @@ import (
"github.com/ProtonMail/go-proton-api" "github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/gopenpgp/v2/crypto" "github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/bradenaw/juniper/stream"
) )
type APIClient interface { type APIClient interface {
@ -41,7 +40,7 @@ type APIClient interface {
GetAllMessageIDs(ctx context.Context, afterID string) ([]string, error) GetAllMessageIDs(ctx context.Context, afterID string) ([]string, error)
CreateDraft(ctx context.Context, addrKR *crypto.KeyRing, req proton.CreateDraftReq) (proton.Message, error) CreateDraft(ctx context.Context, addrKR *crypto.KeyRing, req proton.CreateDraftReq) (proton.Message, error)
UploadAttachment(ctx context.Context, addrKR *crypto.KeyRing, req proton.CreateAttachmentReq) (proton.Attachment, error) UploadAttachment(ctx context.Context, addrKR *crypto.KeyRing, req proton.CreateAttachmentReq) (proton.Attachment, error)
ImportMessages(ctx context.Context, addrKR *crypto.KeyRing, workers, buffer int, req ...proton.ImportReq) (stream.Stream[proton.ImportRes], error) ImportMessages(ctx context.Context, addrKR *crypto.KeyRing, workers, buffer int, req ...proton.ImportReq) (proton.ImportResStream, error)
GetFullMessage(ctx context.Context, messageID string, scheduler proton.Scheduler, storageProvider proton.AttachmentAllocator) (proton.FullMessage, error) GetFullMessage(ctx context.Context, messageID string, scheduler proton.Scheduler, storageProvider proton.AttachmentAllocator) (proton.FullMessage, error)
GetAttachmentInto(ctx context.Context, attachmentID string, reader io.ReaderFrom) error GetAttachmentInto(ctx context.Context, attachmentID string, reader io.ReaderFrom) error
GetAttachment(ctx context.Context, attachmentID string) ([]byte, error) GetAttachment(ctx context.Context, attachmentID string) ([]byte, error)

View File

@ -0,0 +1,51 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package notifications
import (
"time"
"github.com/ProtonMail/go-proton-api"
)
func generatedNotificationDisplayedMetric(status string, value int) proton.ObservabilityMetric {
// Value cannot be zero or negative
if value < 1 {
value = 1
}
return proton.ObservabilityMetric{
Name: "bridge_remoteNotification_displayed_total",
Version: 1,
Timestamp: time.Now().Unix(),
Data: map[string]interface{}{
"Value": value,
"Labels": map[string]string{
"status": status,
},
},
}
}
func GenerateReceivedMetric(count int) proton.ObservabilityMetric {
return generatedNotificationDisplayedMetric("received", count)
}
func GenerateProcessedMetric(count int) proton.ObservabilityMetric {
return generatedNotificationDisplayedMetric("processed", count)
}

View File

@ -0,0 +1,112 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package notifications
import (
"testing"
"time"
"github.com/ProtonMail/go-proton-api"
"github.com/stretchr/testify/require"
)
func TestIsBodyBitfieldValid(t *testing.T) {
tests := []struct {
notification proton.NotificationEvent
isValid bool
}{
{notification: proton.NotificationEvent{Payload: proton.NotificationPayload{Body: ""}}, isValid: true},
{notification: proton.NotificationEvent{Payload: proton.NotificationPayload{Body: "HELLO"}}, isValid: true},
{notification: proton.NotificationEvent{Payload: proton.NotificationPayload{Body: "What is up?"}}, isValid: true},
{notification: proton.NotificationEvent{Payload: proton.NotificationPayload{Body: "123 Hello"}}, isValid: true},
{notification: proton.NotificationEvent{Payload: proton.NotificationPayload{Body: "\\123Hello"}}, isValid: false},
{notification: proton.NotificationEvent{Payload: proton.NotificationPayload{Body: "\\123 Hello"}}, isValid: false},
{notification: proton.NotificationEvent{Payload: proton.NotificationPayload{Body: "\\1test"}}, isValid: false},
{notification: proton.NotificationEvent{Payload: proton.NotificationPayload{Body: "\\1 test"}}, isValid: false},
}
for _, test := range tests {
isValid := isBodyBitfieldValid(test.notification)
require.Equal(t, isValid, !test.isValid)
}
}
// The notification TTL is defined as timestamp by server + predefined duration.
func TestShouldSendAndStore(t *testing.T) {
getDirFn := func(dir string) func() (string, error) {
return func() (string, error) {
return dir, nil
}
}
dir1 := t.TempDir()
dir2 := t.TempDir()
store := NewStore(getDirFn(dir1))
notification1 := proton.NotificationEvent{ID: "1", Payload: proton.NotificationPayload{Title: "test1", Subtitle: "test1", Body: "test1"}, Time: time.Now().Unix()}
notification2 := proton.NotificationEvent{ID: "2", Payload: proton.NotificationPayload{Title: "test2", Subtitle: "test2", Body: "test2"}, Time: time.Now().Unix()}
notification3 := proton.NotificationEvent{ID: "3", Payload: proton.NotificationPayload{Title: "test3", Subtitle: "test3", Body: "test3"}, Time: time.Now().Unix()}
notificationAlt1 := proton.NotificationEvent{ID: "1", Payload: proton.NotificationPayload{Title: "testAlt1", Subtitle: "test1", Body: "test1"}, Time: time.Now().Unix()}
notificationAlt2 := proton.NotificationEvent{ID: "1", Payload: proton.NotificationPayload{Title: "test2", Subtitle: "testAlt2", Body: "test2"}, Time: time.Now().Unix()}
require.Equal(t, true, store.shouldSendAndStore(notification1))
require.Equal(t, true, store.shouldSendAndStore(notification2))
require.Equal(t, true, store.shouldSendAndStore(notification3))
require.Equal(t, true, store.shouldSendAndStore(notificationAlt1))
require.Equal(t, true, store.shouldSendAndStore(notificationAlt2))
require.Equal(t, false, store.shouldSendAndStore(notification1))
require.Equal(t, false, store.shouldSendAndStore(notification2))
require.Equal(t, false, store.shouldSendAndStore(notification3))
store = NewStore(getDirFn(dir1))
// These should be cached in the file
require.Equal(t, false, store.shouldSendAndStore(notification1))
require.Equal(t, false, store.shouldSendAndStore(notification2))
require.Equal(t, false, store.shouldSendAndStore(notification3))
store = NewStore(getDirFn(dir2))
timeOffset = 1 * time.Second
// We're basing the time based on when the notification is sent
// Let's reset it.
notification1 = proton.NotificationEvent{ID: "1", Payload: proton.NotificationPayload{Title: "test1", Subtitle: "test1", Body: "test1"}, Time: time.Now().Unix()}
notification2 = proton.NotificationEvent{ID: "2", Payload: proton.NotificationPayload{Title: "test2", Subtitle: "test2", Body: "test2"}, Time: time.Now().Unix()}
notification3 = proton.NotificationEvent{ID: "3", Payload: proton.NotificationPayload{Title: "test3", Subtitle: "test3", Body: "test3"}, Time: time.Now().Unix()}
require.Equal(t, true, store.shouldSendAndStore(notification1))
require.Equal(t, true, store.shouldSendAndStore(notification2))
require.Equal(t, true, store.shouldSendAndStore(notification3))
require.Equal(t, false, store.shouldSendAndStore(notification1))
require.Equal(t, false, store.shouldSendAndStore(notification2))
require.Equal(t, false, store.shouldSendAndStore(notification3))
time.Sleep(1200 * time.Millisecond)
require.Equal(t, true, store.shouldSendAndStore(notification1))
require.Equal(t, true, store.shouldSendAndStore(notification2))
require.Equal(t, true, store.shouldSendAndStore(notification3))
store = NewStore(getDirFn(dir2))
require.Equal(t, true, store.shouldSendAndStore(notification1))
require.Equal(t, true, store.shouldSendAndStore(notification2))
require.Equal(t, true, store.shouldSendAndStore(notification3))
}

View File

@ -0,0 +1,156 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package notifications
import (
"context"
"fmt"
"regexp"
"strings"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/logging"
"github.com/ProtonMail/proton-bridge/v3/internal/services/observability"
"github.com/ProtonMail/proton-bridge/v3/internal/services/orderedtasks"
"github.com/ProtonMail/proton-bridge/v3/internal/services/userevents"
"github.com/ProtonMail/proton-bridge/v3/internal/unleash"
"github.com/sirupsen/logrus"
)
type Service struct {
userID string
log *logrus.Entry
eventService userevents.Subscribable
subscription *userevents.EventChanneledSubscriber
eventPublisher events.EventPublisher
store *Store
getFlagValueFn unleash.GetFlagValueFn
pushObservabilityMetricFn observability.PushObsMetricFn
}
const bitfieldRegexPattern = `^\\\d+`
const disableNotificationsKillSwitch = "InboxBridgeEventLoopNotificationDisabled"
func NewService(userID string, service userevents.Subscribable, eventPublisher events.EventPublisher, store *Store,
getFlagFn unleash.GetFlagValueFn, pushMetricFn observability.PushObsMetricFn) *Service {
return &Service{
userID: userID,
log: logrus.WithFields(logrus.Fields{
"user": userID,
"service": "notification",
}),
eventService: service,
subscription: userevents.NewEventSubscriber(
fmt.Sprintf("notifications-%v", userID)),
eventPublisher: eventPublisher,
store: store,
getFlagValueFn: getFlagFn,
pushObservabilityMetricFn: pushMetricFn,
}
}
func (s *Service) Start(ctx context.Context, group *orderedtasks.OrderedCancelGroup) {
group.Go(ctx, s.userID, "notification-service", s.run)
}
func (s *Service) run(ctx context.Context) {
s.log.Info("Starting service main loop")
defer s.log.Info("Exiting service main loop")
eventHandler := userevents.EventHandler{
NotificationHandler: s,
}
s.eventService.Subscribe(s.subscription)
defer s.eventService.Unsubscribe(s.subscription)
for {
select {
case <-ctx.Done():
return
case e, ok := <-s.subscription.OnEventCh():
if !ok {
continue
}
e.Consume(func(event proton.Event) error { return eventHandler.OnEvent(ctx, event) })
}
}
}
func (s *Service) HandleNotificationEvents(ctx context.Context, notificationEvents []proton.NotificationEvent) error {
if s.getFlagValueFn(disableNotificationsKillSwitch) {
s.log.Info("Received notification events. Skipping as kill switch is enabled.")
return nil
}
s.log.Debug("Handling notification events")
// Publish observability metrics that we've received notifications
s.pushObservabilityMetricFn(GenerateReceivedMetric(len(notificationEvents)))
for _, event := range notificationEvents {
ctx = logging.WithLogrusField(ctx, "notificationID", event.ID)
switch strings.ToLower(event.Type) {
case "bridge_modal":
{
// We currently don't support any notification types with bitfields in the body.
if isBodyBitfieldValid(event) {
continue
}
shouldSend := s.store.shouldSendAndStore(event)
if !shouldSend {
s.log.Info("Skipping notification event. Notification was displayed previously")
continue
}
s.log.Info("Publishing notification event. notificationID:", event.ID) // \todo BRIDGE-141 - change this to UID once it is available
s.eventPublisher.PublishEvent(ctx, events.UserNotification{UserID: s.userID, Title: event.Payload.Title,
Subtitle: event.Payload.Subtitle, Body: event.Payload.Body})
// Publish observability metric that we've successfully processed notifications
s.pushObservabilityMetricFn(GenerateProcessedMetric(1))
}
default:
s.log.Debug("Skipping notification event. Notification type is not related to bridge:", event.Type)
continue
}
}
return nil
}
// We will (potentially) encode different notification functionalities based on a starting bitfield "\NUMBER" in the
// payload Body. Currently, we don't support this, but we might in the future. This is so versions of Bridge that don't
// support this functionality won't be display such a message.
func isBodyBitfieldValid(notification proton.NotificationEvent) bool {
match, err := regexp.MatchString(bitfieldRegexPattern, notification.Payload.Body)
if err != nil {
return false
}
return match
}

View File

@ -0,0 +1,161 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package notifications
import (
"crypto"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
"github.com/ProtonMail/go-proton-api"
"github.com/sirupsen/logrus"
"golang.org/x/exp/maps"
)
// not-const so we can unit test the functionality.
var timeOffset = 24 * time.Hour //nolint:gochecknoglobals
const filename = "notification_cache"
type Store struct {
displayedMessages map[string]time.Time
displayedMessagesLock sync.Mutex
useCache bool
cacheFilepath string
cacheLock sync.Mutex
log *logrus.Entry
}
func NewStore(getCachePath func() (string, error)) *Store {
log := logrus.WithField("pkg", "notification-store")
useCacheFile := true
cachePath, err := getCachePath()
if err != nil {
useCacheFile = false
log.WithError(err).Error("Could not obtain cache directory")
}
store := &Store{
displayedMessages: make(map[string]time.Time),
useCache: useCacheFile,
cacheFilepath: filepath.Clean(filepath.Join(cachePath, filename)),
log: log,
}
store.readCache()
return store
}
func generateHash(payload proton.NotificationPayload) string {
hash := crypto.SHA256.New()
hash.Write([]byte(payload.Body + payload.Subtitle + payload.Title))
return hex.EncodeToString(hash.Sum(nil))
}
func (s *Store) shouldSendAndStore(notification proton.NotificationEvent) bool {
s.displayedMessagesLock.Lock()
defer s.displayedMessagesLock.Unlock()
// \todo BRIDGE-141 - Add an additional check for the API returned UID
uid := generateHash(notification.Payload)
value, ok := s.displayedMessages[uid]
if !ok {
s.displayedMessages[uid] = time.Unix(notification.Time, 0).Add(timeOffset)
s.writeCache()
return true
}
if !time.Now().After(value) {
return false
}
s.displayedMessages[uid] = time.Unix(notification.Time, 0).Add(timeOffset)
s.writeCache()
return true
}
func (s *Store) readCache() {
if !s.useCache {
return
}
s.cacheLock.Lock()
defer s.cacheLock.Unlock()
file, err := os.Open(s.cacheFilepath)
if err != nil {
s.log.WithError(err).Error("Unable to open cache file")
return
}
defer func(file *os.File) {
err := file.Close()
if err != nil {
s.log.WithError(err).Error("Unable to close cache file after read")
}
}(file)
s.displayedMessagesLock.Lock()
defer s.displayedMessagesLock.Unlock()
if err = json.NewDecoder(file).Decode(&s.displayedMessages); err != nil {
s.log.WithError(err).Error("Unable to decode cache file")
}
// Remove redundant data
curTime := time.Now()
maps.DeleteFunc(s.displayedMessages, func(_ string, value time.Time) bool {
return curTime.After(value)
})
}
func (s *Store) writeCache() {
if !s.useCache {
return
}
s.cacheLock.Lock()
defer s.cacheLock.Unlock()
file, err := os.Create(s.cacheFilepath)
if err != nil {
s.log.WithError(err).Info("Unable to create cache file.")
return
}
defer func(file *os.File) {
err := file.Close()
if err != nil {
s.log.WithError(err).Error("Unable to close cache file after write")
}
}(file)
// We don't lock the mutex here as the parent does that already
if err = json.NewEncoder(file).Encode(s.displayedMessages); err != nil {
s.log.WithError(err).Error("Unable to encode data to cache file")
}
}

View File

@ -0,0 +1,281 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package observability
import (
"context"
"sync"
"time"
"github.com/ProtonMail/gluon/async"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal/services/telemetry"
"github.com/sirupsen/logrus"
)
// Non-const for testing.
var throttleDuration = 5 * time.Second //nolint:gochecknoglobals
const (
maxStorageSize = 5000
maxBatchSize = 1000
)
type PushObsMetricFn func(metric proton.ObservabilityMetric)
type client struct {
isTelemetryEnabled func(context.Context) bool
sendMetrics func(context.Context, proton.ObservabilityBatch) error
}
type Service struct {
ctx context.Context
cancel context.CancelFunc
panicHandler async.PanicHandler
lastDispatch time.Time
isDispatchScheduled bool
signalDataArrived chan struct{}
signalDispatch chan struct{}
log *logrus.Entry
metricStore []proton.ObservabilityMetric
metricStoreLock sync.Mutex
userClientStore map[string]*client
userClientStoreLock sync.Mutex
}
func NewService(ctx context.Context, panicHandler async.PanicHandler) *Service {
ctx, cancel := context.WithCancel(ctx)
service := &Service{
ctx: ctx,
cancel: cancel,
panicHandler: panicHandler,
lastDispatch: time.Now().Add(-throttleDuration),
signalDataArrived: make(chan struct{}, 1),
signalDispatch: make(chan struct{}, 1),
log: logrus.WithFields(logrus.Fields{"pkg": "observability"}),
metricStore: make([]proton.ObservabilityMetric, 0),
userClientStore: make(map[string]*client),
}
return service
}
func (s *Service) Run() {
s.log.Info("Starting service")
go func() {
s.start()
}()
}
// When new data is received, we determine if we can immediately send the request.
// First, we check if a dispatch operation is already scheduled. If it is, we do nothing.
// If no dispatch is scheduled, we verify if the required time interval has passed since the last send.
// If the interval hasn't passed, we schedule the dispatch to occur when the threshold is met.
// If the interval has passed, we initiate an immediate dispatch.
func (s *Service) start() {
defer async.HandlePanic(s.panicHandler)
for {
select {
case <-s.ctx.Done():
return
case <-s.signalDispatch:
s.dispatchData()
case <-s.signalDataArrived:
if s.isDispatchScheduled {
continue
}
if time.Since(s.lastDispatch) <= throttleDuration {
s.scheduleDispatch()
continue
}
s.sendSignal(s.signalDispatch)
}
}
}
func (s *Service) dispatchData() {
s.isDispatchScheduled = false // Only accessed via a single goroutine, so no mutexes.
if !s.haveMetricsAndClients() {
return
}
// Get a copy of the metrics we want to send and batch them accordingly
var numberOfRemainingMetrics int
var metricsToSend []proton.ObservabilityMetric
s.withMetricStoreLock(func() {
numberOfMetricsToSend := min(len(s.metricStore), maxBatchSize)
metricsToSend = make([]proton.ObservabilityMetric, numberOfMetricsToSend)
copy(metricsToSend, s.metricStore[:numberOfMetricsToSend])
s.metricStore = s.metricStore[numberOfMetricsToSend:]
numberOfRemainingMetrics = len(s.metricStore)
})
// Send them out to the endpoint
telemetryEnabled := s.dispatchViaClient(&metricsToSend)
// If there are more metric updates than the max batch limit and telemetry is enabled for one of the clients
// then we immediately schedule another dispatch.
if numberOfRemainingMetrics > 0 && telemetryEnabled {
s.scheduleDispatch()
}
}
// dispatchViaClient - return value tells us whether telemetry is enabled
// such that we know whether to schedule another dispatch if more data is present.
func (s *Service) dispatchViaClient(metricsToSend *[]proton.ObservabilityMetric) bool {
s.log.Info("Sending observability data.")
s.userClientStoreLock.Lock()
defer s.userClientStoreLock.Unlock()
for _, value := range s.userClientStore {
if !value.isTelemetryEnabled(s.ctx) {
continue
}
if err := value.sendMetrics(s.ctx, proton.ObservabilityBatch{Metrics: *metricsToSend}); err != nil {
s.log.WithError(err).Error("Issue occurred when sending observability data.")
} else {
s.log.Info("Successfully sent observability data.")
}
s.lastDispatch = time.Now()
return true
}
s.log.Info("Could not send observability data. Telemetry is not enabled.")
return false
}
func (s *Service) scheduleDispatch() {
waitTime := throttleDuration - time.Since(s.lastDispatch)
if waitTime <= 0 {
s.sendSignal(s.signalDispatch)
return
}
s.log.Info("Scheduling observability data sending")
s.isDispatchScheduled = true
go func() {
defer async.HandlePanic(s.panicHandler)
select {
case <-s.ctx.Done():
return
case <-time.After(waitTime):
s.sendSignal(s.signalDispatch)
}
}()
}
func (s *Service) AddMetric(metric proton.ObservabilityMetric) {
s.withMetricStoreLock(func() {
metricStoreLength := len(s.metricStore)
if metricStoreLength >= maxStorageSize {
s.log.Info("Max metric storage size has been exceeded. Dropping oldest metrics.")
dropCount := metricStoreLength - maxStorageSize + 1
s.metricStore = s.metricStore[dropCount:]
}
s.metricStore = append(s.metricStore, metric)
})
s.sendSignal(s.signalDataArrived)
}
func (s *Service) RegisterUserClient(userID string, protonClient *proton.Client, telemetryService *telemetry.Service) {
s.log.Info("Registering user client, ID:", userID)
s.withUserClientStoreLock(func() {
s.userClientStore[userID] = &client{
isTelemetryEnabled: telemetryService.IsTelemetryEnabled,
sendMetrics: protonClient.SendObservabilityBatch,
}
})
// There may be a case where we already have metric updates stored, so try to flush;
s.sendSignal(s.signalDataArrived)
}
func (s *Service) DeregisterUserClient(userID string) {
s.log.Info("De-registering user client, ID:", userID)
s.withUserClientStoreLock(func() {
delete(s.userClientStore, userID)
})
}
func (s *Service) Stop() {
s.log.Info("Stopping service")
s.cancel()
close(s.signalDataArrived)
close(s.signalDispatch)
}
// Utility functions below.
func (s *Service) haveMetricsAndClients() bool {
s.metricStoreLock.Lock()
s.userClientStoreLock.Lock()
defer s.metricStoreLock.Unlock()
defer s.userClientStoreLock.Unlock()
return len(s.metricStore) > 0 && len(s.userClientStore) > 0
}
func (s *Service) withUserClientStoreLock(fn func()) {
s.userClientStoreLock.Lock()
defer s.userClientStoreLock.Unlock()
fn()
}
func (s *Service) withMetricStoreLock(fn func()) {
s.metricStoreLock.Lock()
defer s.metricStoreLock.Unlock()
fn()
}
// We use buffered channels; we shouldn't block them.
func (s *Service) sendSignal(channel chan struct{}) {
select {
case channel <- struct{}{}:
default:
}
}
// ModifyThrottlePeriod - used for testing.
func ModifyThrottlePeriod(duration time.Duration) {
throttleDuration = duration
}

View File

@ -136,7 +136,7 @@ func (b *BuildStage) run(ctx context.Context) {
return nil return nil
} }
result, err := parallel.MapContext(ctx, maxMessagesInParallel, chunk, func(ctx context.Context, msg proton.FullMessage) (BuildResult, error) { result, err := parallel.MapContext(ctx, maxMessagesInParallel, chunk, func(_ context.Context, msg proton.FullMessage) (BuildResult, error) {
defer async.HandlePanic(b.panicHandler) defer async.HandlePanic(b.panicHandler)
kr, ok := addrKRs[msg.AddressID] kr, ok := addrKRs[msg.AddressID]

View File

@ -253,7 +253,7 @@ func TestBuildStage_OtherErrorsFailJob(t *testing.T) {
expectedErr := errors.New("something went wrong") expectedErr := errors.New("something went wrong")
tj.messageBuilder.EXPECT().WithKeys(gomock.Any()).DoAndReturn(func(f func(*crypto.KeyRing, map[string]*crypto.KeyRing) error) error { tj.messageBuilder.EXPECT().WithKeys(gomock.Any()).DoAndReturn(func(_ func(*crypto.KeyRing, map[string]*crypto.KeyRing) error) error {
return expectedErr return expectedErr
}) })

View File

@ -119,12 +119,12 @@ func TestAutoDownloadScale_429or500x(t *testing.T) {
for _, d := range data { for _, d := range data {
switch d { switch d {
case "m7": case "m7":
call429 := client.EXPECT().GetMessage(gomock.Any(), gomock.Eq("m7")).DoAndReturn(func(_ context.Context, id string) (proton.Message, error) { call429 := client.EXPECT().GetMessage(gomock.Any(), gomock.Eq("m7")).DoAndReturn(func(_ context.Context, _ string) (proton.Message, error) {
return proton.Message{}, &proton.APIError{Status: 429} return proton.Message{}, &proton.APIError{Status: 429}
}) })
client.EXPECT().GetMessage(gomock.Any(), gomock.Eq("m7")).After(call429).DoAndReturn(autoDownloadScaleClientDoAndReturn) client.EXPECT().GetMessage(gomock.Any(), gomock.Eq("m7")).After(call429).DoAndReturn(autoDownloadScaleClientDoAndReturn)
case "m23": case "m23":
call503 := client.EXPECT().GetMessage(gomock.Any(), gomock.Eq("m23")).DoAndReturn(func(_ context.Context, id string) (proton.Message, error) { call503 := client.EXPECT().GetMessage(gomock.Any(), gomock.Eq("m23")).DoAndReturn(func(_ context.Context, _ string) (proton.Message, error) {
return proton.Message{}, &proton.APIError{Status: 503} return proton.Message{}, &proton.APIError{Status: 503}
}) })
client.EXPECT().GetMessage(gomock.Any(), gomock.Eq("m23")).After(call503).DoAndReturn(autoDownloadScaleClientDoAndReturn) client.EXPECT().GetMessage(gomock.Any(), gomock.Eq("m23")).After(call503).DoAndReturn(autoDownloadScaleClientDoAndReturn)

View File

@ -191,7 +191,7 @@ func TestService_OnBadEventServiceIsPaused(t *testing.T) {
NewEventID: secondEventID, NewEventID: secondEventID,
EventInfo: secondEvent[0].String(), EventInfo: secondEvent[0].String(),
Error: fmt.Errorf("failed to apply message events: %w", badEventErr), Error: fmt.Errorf("failed to apply message events: %w", badEventErr),
}).Do(func(_ context.Context, event events.Event) { }).Do(func(_ context.Context, _ events.Event) {
group.Go(context.Background(), "", "", func(_ context.Context) { group.Go(context.Background(), "", "", func(_ context.Context) {
// Use background context to avoid having the request cancelled // Use background context to avoid having the request cancelled
require.True(t, service.IsPaused()) require.True(t, service.IsPaused())

View File

@ -38,6 +38,7 @@ type EventHandler struct {
MessageHandler MessageEventHandler MessageHandler MessageEventHandler
UsedSpaceHandler UserUsedSpaceEventHandler UsedSpaceHandler UserUsedSpaceEventHandler
UserSettingsHandler UserSettingsHandler UserSettingsHandler UserSettingsHandler
NotificationHandler NotificationEventHandler
} }
func (e EventHandler) OnEvent(ctx context.Context, event proton.Event) error { func (e EventHandler) OnEvent(ctx context.Context, event proton.Event) error {
@ -87,6 +88,12 @@ func (e EventHandler) OnEvent(ctx context.Context, event proton.Event) error {
} }
} }
if len(event.Notifications) != 0 && e.NotificationHandler != nil {
if err := e.NotificationHandler.HandleNotificationEvents(ctx, event.Notifications); err != nil {
return fmt.Errorf("failed to apply notification events: %w", err)
}
}
return nil return nil
} }
@ -116,3 +123,7 @@ type LabelEventHandler interface {
type MessageEventHandler interface { type MessageEventHandler interface {
HandleMessageEvents(ctx context.Context, events []proton.MessageEvent) error HandleMessageEvents(ctx context.Context, events []proton.MessageEvent) error
} }
type NotificationEventHandler interface {
HandleNotificationEvents(ctx context.Context, events []proton.NotificationEvent) error
}

View File

@ -0,0 +1,43 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package telemetry
type RepairData struct {
MeasurementGroup string
Event string
Values map[string]string
Dimensions map[string]string
}
func NewRepairTriggerData() RepairData {
return RepairData{
MeasurementGroup: "bridge.any.repair",
Event: "repair_trigger",
Values: map[string]string{},
Dimensions: map[string]string{},
}
}
func NewRepairDeferredTriggerData() RepairData {
return RepairData{
MeasurementGroup: "bridge.any.repair",
Event: "repair_deferred_trigger",
Values: map[string]string{},
Dimensions: map[string]string{},
}
}

235
internal/unleash/service.go Normal file
View File

@ -0,0 +1,235 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package unleash
import (
"context"
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
"github.com/ProtonMail/gluon/async"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal/service"
"github.com/sirupsen/logrus"
)
var pollPeriod = 10 * time.Minute //nolint:gochecknoglobals
var pollJitter = 2 * time.Minute //nolint:gochecknoglobals
const filename = "unleash_flags"
type requestFeaturesFn func(ctx context.Context) (proton.FeatureFlagResult, error)
type GetFlagValueFn func(key string) bool
type Service struct {
panicHandler async.PanicHandler
timer *proton.Ticker
ctx context.Context
cancel context.CancelFunc
log *logrus.Entry
ffStore map[string]bool
ffStoreLock sync.Mutex
cacheFilepath string
cacheFileLock sync.Mutex
channel chan map[string]bool
getFeaturesFn func(ctx context.Context) (proton.FeatureFlagResult, error)
}
func NewBridgeService(ctx context.Context, api *proton.Manager, locator service.Locator, panicHandler async.PanicHandler) *Service {
log := logrus.WithField("service", "unleash")
cacheDir, err := locator.ProvideUnleashCachePath()
if err != nil {
log.Warn("Could not find or create unleash cache directory")
}
cachePath := filepath.Clean(filepath.Join(cacheDir, filename))
return newService(ctx, func(ctx context.Context) (proton.FeatureFlagResult, error) {
return api.GetFeatures(ctx)
}, log, cachePath, panicHandler)
}
func newService(ctx context.Context, fn requestFeaturesFn, log *logrus.Entry, cachePath string, panicHandler async.PanicHandler) *Service {
ctx, cancel := context.WithCancel(ctx)
unleashService := &Service{
panicHandler: panicHandler,
timer: proton.NewTicker(pollPeriod, pollJitter, panicHandler),
ctx: ctx,
cancel: cancel,
log: log,
ffStore: make(map[string]bool),
cacheFilepath: cachePath,
channel: make(chan map[string]bool),
getFeaturesFn: fn,
}
unleashService.readCacheFile()
return unleashService
}
func readResponseData(data proton.FeatureFlagResult) map[string]bool {
featureData := make(map[string]bool)
for _, el := range data.Toggles {
featureData[el.Name] = el.Enabled
}
return featureData
}
func (s *Service) readCacheFile() {
defer s.cacheFileLock.Unlock()
s.cacheFileLock.Lock()
file, err := os.Open(s.cacheFilepath)
if err != nil {
s.log.WithError(err).Error("Unable to open cache file")
return
}
defer func(file *os.File) {
err := file.Close()
if err != nil {
s.log.WithError(err).Error("Unable to close cache file after read")
}
}(file)
s.ffStoreLock.Lock()
defer s.ffStoreLock.Unlock()
if err = json.NewDecoder(file).Decode(&s.ffStore); err != nil {
s.log.WithError(err).Error("Unable to decode cache file")
}
}
func (s *Service) writeCacheFile() {
defer s.cacheFileLock.Unlock()
s.cacheFileLock.Lock()
file, err := os.Create(s.cacheFilepath)
if err != nil {
s.log.WithError(err).Error("Unable to create cache file")
return
}
defer func(file *os.File) {
err := file.Close()
if err != nil {
s.log.WithError(err).Error("Unable to close cache file after write")
}
}(file)
s.ffStoreLock.Lock()
defer s.ffStoreLock.Unlock()
if err = json.NewEncoder(file).Encode(s.ffStore); err != nil {
s.log.WithError(err).Error("Unable to encode data to cache file")
}
}
func (s *Service) Run() {
s.log.Info("Starting service")
go func() {
s.runFlagPoll()
}()
go func() {
s.runReceiver()
}()
}
func (s *Service) runFlagPoll() {
defer async.HandlePanic(s.panicHandler)
defer s.timer.Stop()
s.log.Info("Starting poll service")
data, err := s.getFeaturesFn(s.ctx)
if err != nil {
s.log.WithError(err).Error("Failed to get flags from server")
} else {
s.channel <- readResponseData(data)
}
for {
select {
case <-s.ctx.Done():
return
case <-s.timer.C:
s.log.Info("Polling flag service")
data, err := s.getFeaturesFn(s.ctx)
if err != nil {
s.log.WithError(err).Error("Failed to get feature flags from server")
continue
}
s.channel <- readResponseData(data)
}
}
}
func (s *Service) runReceiver() {
defer async.HandlePanic(s.panicHandler)
s.log.Info("Starting receiver service")
for {
select {
case <-s.ctx.Done():
return
case res := <-s.channel:
s.ffStoreLock.Lock()
s.ffStore = res
s.ffStoreLock.Unlock()
s.writeCacheFile()
}
}
}
func (s *Service) GetFlagValue(key string) bool {
defer s.ffStoreLock.Unlock()
s.ffStoreLock.Lock()
val, ok := s.ffStore[key]
if !ok {
return false
}
return val
}
func (s *Service) Close() {
s.log.Info("Closing service")
s.cancel()
close(s.channel)
}
// ModifyPollPeriodAndJitter is only used for testing.
func ModifyPollPeriodAndJitter(pollInterval, jitterInterval time.Duration) {
pollPeriod = pollInterval
pollJitter = jitterInterval
}

View File

@ -32,7 +32,7 @@ func BenchmarkAddrKeyRing(b *testing.B) {
b.StopTimer() b.StopTimer()
withAPI(b, context.Background(), func(ctx context.Context, s *server.Server, m *proton.Manager) { withAPI(b, context.Background(), func(ctx context.Context, s *server.Server, m *proton.Manager) {
withAccount(b, s, "username", "password", []string{"email@pm.me"}, func(userID string, addrIDs []string) { withAccount(b, s, "username", "password", []string{"email@pm.me"}, func(_ string, _ []string) {
withUser(b, ctx, s, m, "username", "password", func(user *User) { withUser(b, ctx, s, m, "username", "password", func(user *User) {
b.StartTimer() b.StartTimer()
@ -43,7 +43,7 @@ func BenchmarkAddrKeyRing(b *testing.B) {
require.NoError(b, err) require.NoError(b, err)
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
require.NoError(b, usertypes.WithAddrKRs(apiUser, apiAddrs, user.vault.KeyPass(), func(_ *crypto.KeyRing, addrKRs map[string]*crypto.KeyRing) error { require.NoError(b, usertypes.WithAddrKRs(apiUser, apiAddrs, user.vault.KeyPass(), func(_ *crypto.KeyRing, _ map[string]*crypto.KeyRing) error {
return nil return nil
})) }))
} }

View File

@ -0,0 +1,65 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package user
import (
"context"
"encoding/json"
"github.com/ProtonMail/proton-bridge/v3/internal/telemetry"
)
func (user *User) SendRepairTrigger(ctx context.Context) {
if !user.IsTelemetryEnabled(ctx) {
return
}
triggerData := telemetry.NewRepairTriggerData()
data, err := json.Marshal(triggerData)
if err != nil {
user.log.WithError(err).Error("Failed to parse repair trigger data.")
return
}
if err := user.SendTelemetry(ctx, data); err != nil {
user.log.WithError(err).Error("Failed to send repair trigger event.")
return
}
user.log.Info("Repair trigger event successfully sent.")
}
func (user *User) SendRepairDeferredTrigger(ctx context.Context) {
if !user.IsTelemetryEnabled(ctx) {
return
}
deferredTriggerData := telemetry.NewRepairDeferredTriggerData()
data, err := json.Marshal(deferredTriggerData)
if err != nil {
user.log.WithError(err).Error("Failed to parse deferred repair trigger data.")
return
}
if err := user.SendTelemetry(ctx, data); err != nil {
user.log.WithError(err).Error("Failed to send deferred repair trigger event.")
return
}
user.log.Info("Deferred repair trigger event successfully sent.")
}

View File

@ -31,6 +31,8 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/events" "github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/safe" "github.com/ProtonMail/proton-bridge/v3/internal/safe"
"github.com/ProtonMail/proton-bridge/v3/internal/services/imapservice" "github.com/ProtonMail/proton-bridge/v3/internal/services/imapservice"
"github.com/ProtonMail/proton-bridge/v3/internal/services/notifications"
"github.com/ProtonMail/proton-bridge/v3/internal/services/observability"
"github.com/ProtonMail/proton-bridge/v3/internal/services/orderedtasks" "github.com/ProtonMail/proton-bridge/v3/internal/services/orderedtasks"
"github.com/ProtonMail/proton-bridge/v3/internal/services/sendrecorder" "github.com/ProtonMail/proton-bridge/v3/internal/services/sendrecorder"
"github.com/ProtonMail/proton-bridge/v3/internal/services/smtp" "github.com/ProtonMail/proton-bridge/v3/internal/services/smtp"
@ -39,6 +41,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/services/userevents" "github.com/ProtonMail/proton-bridge/v3/internal/services/userevents"
"github.com/ProtonMail/proton-bridge/v3/internal/services/useridentity" "github.com/ProtonMail/proton-bridge/v3/internal/services/useridentity"
"github.com/ProtonMail/proton-bridge/v3/internal/telemetry" "github.com/ProtonMail/proton-bridge/v3/internal/telemetry"
"github.com/ProtonMail/proton-bridge/v3/internal/unleash"
"github.com/ProtonMail/proton-bridge/v3/internal/usertypes" "github.com/ProtonMail/proton-bridge/v3/internal/usertypes"
"github.com/ProtonMail/proton-bridge/v3/internal/vault" "github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/pkg/algo" "github.com/ProtonMail/proton-bridge/v3/pkg/algo"
@ -80,11 +83,14 @@ type User struct {
// goStatusProgress triggers a check/sending if progress is needed. // goStatusProgress triggers a check/sending if progress is needed.
goStatusProgress func() goStatusProgress func()
eventService *userevents.Service eventService *userevents.Service
identityService *useridentity.Service identityService *useridentity.Service
smtpService *smtp.Service smtpService *smtp.Service
imapService *imapservice.Service imapService *imapservice.Service
telemetryService *telemetryservice.Service telemetryService *telemetryservice.Service
notificationService *notifications.Service
observabilityService *observability.Service
serviceGroup *orderedtasks.OrderedCancelGroup serviceGroup *orderedtasks.OrderedCancelGroup
} }
@ -104,8 +110,12 @@ func New(
smtpServerManager smtp.ServerManager, smtpServerManager smtp.ServerManager,
eventSubscription events.Subscription, eventSubscription events.Subscription,
syncService syncservice.Regulator, syncService syncservice.Regulator,
observabilityService *observability.Service,
syncConfigDir string, syncConfigDir string,
isNew bool, isNew bool,
notificationStore *notifications.Store,
getFlagValFn unleash.GetFlagValueFn,
pushObservabilityMetric observability.PushObsMetricFn,
) (*User, error) { ) (*User, error) {
user, err := newImpl( user, err := newImpl(
ctx, ctx,
@ -122,8 +132,12 @@ func New(
smtpServerManager, smtpServerManager,
eventSubscription, eventSubscription,
syncService, syncService,
observabilityService,
syncConfigDir, syncConfigDir,
isNew, isNew,
notificationStore,
getFlagValFn,
pushObservabilityMetric,
) )
if err != nil { if err != nil {
// Cleanup any pending resources on error // Cleanup any pending resources on error
@ -153,8 +167,12 @@ func newImpl(
smtpServerManager smtp.ServerManager, smtpServerManager smtp.ServerManager,
eventSubscription events.Subscription, eventSubscription events.Subscription,
syncService syncservice.Regulator, syncService syncservice.Regulator,
observabilityService *observability.Service,
syncConfigDir string, syncConfigDir string,
isNew bool, isNew bool,
notificationStore *notifications.Store,
getFlagValueFn unleash.GetFlagValueFn,
pushObservabilityMetric observability.PushObsMetricFn,
) (*User, error) { ) (*User, error) {
logrus.WithField("userID", apiUser.ID).Info("Creating new user") logrus.WithField("userID", apiUser.ID).Info("Creating new user")
@ -215,6 +233,8 @@ func newImpl(
serviceGroup: orderedtasks.NewOrderedCancelGroup(crashHandler), serviceGroup: orderedtasks.NewOrderedCancelGroup(crashHandler),
smtpService: nil, smtpService: nil,
observabilityService: observabilityService,
} }
user.eventService = userevents.NewService( user.eventService = userevents.NewService(
@ -270,6 +290,8 @@ func newImpl(
showAllMail, showAllMail,
) )
user.notificationService = notifications.NewService(user.id, user.eventService, user, notificationStore, getFlagValueFn, pushObservabilityMetric)
// Check for status_progress when triggered. // Check for status_progress when triggered.
user.goStatusProgress = user.tasks.PeriodicOrTrigger(configstatus.ProgressCheckInterval, 0, func(ctx context.Context) { user.goStatusProgress = user.tasks.PeriodicOrTrigger(configstatus.ProgressCheckInterval, 0, func(ctx context.Context) {
user.SendConfigStatusProgress(ctx) user.SendConfigStatusProgress(ctx)
@ -318,6 +340,12 @@ func newImpl(
// Start Identity Service // Start Identity Service
user.identityService.Start(ctx, user.serviceGroup) user.identityService.Start(ctx, user.serviceGroup)
// Add user client to observability service
observabilityService.RegisterUserClient(user.id, client, user.telemetryService)
// Start Notification service
user.notificationService.Start(ctx, user.serviceGroup)
// Start SMTP Service // Start SMTP Service
if err := user.smtpService.Start(ctx, user.serviceGroup); err != nil { if err := user.smtpService.Start(ctx, user.serviceGroup); err != nil {
return user, fmt.Errorf("failed to start smtp service: %w", err) return user, fmt.Errorf("failed to start smtp service: %w", err)
@ -586,6 +614,9 @@ func (user *User) Logout(ctx context.Context, withAPI bool) error {
user.tasks.CancelAndWait() user.tasks.CancelAndWait()
// Close user observability service.
user.observabilityService.DeregisterUserClient(user.id)
// Stop Services // Stop Services
user.serviceGroup.CancelAndWait() user.serviceGroup.CancelAndWait()
@ -619,6 +650,9 @@ func (user *User) Close() {
// Stop any ongoing background tasks. // Stop any ongoing background tasks.
user.tasks.CancelAndWait() user.tasks.CancelAndWait()
// Close user observability service.
user.observabilityService.DeregisterUserClient(user.id)
// Stop Services // Stop Services
user.serviceGroup.CancelAndWait() user.serviceGroup.CancelAndWait()
@ -727,12 +761,18 @@ func (user *User) VerifyResyncAndExecute() {
user.log.WithError(err).Error("Failed to disable re-sync flag in user vault. UserID:", user.ID()) user.log.WithError(err).Error("Failed to disable re-sync flag in user vault. UserID:", user.ID())
} }
if err := user.ResyncIMAP(); err != nil { user.SendRepairDeferredTrigger(context.Background())
if err := user.resyncIMAP(); err != nil {
user.log.WithError(err).Error("Failed re-syncing IMAP for userID", user.ID()) user.log.WithError(err).Error("Failed re-syncing IMAP for userID", user.ID())
} }
} }
} }
func (user *User) ResyncIMAP() error { func (user *User) TriggerRepair() error {
user.SendRepairTrigger(context.Background())
return user.resyncIMAP()
}
func (user *User) resyncIMAP() error {
return user.imapService.Resync(context.Background()) return user.imapService.Resync(context.Background())
} }

View File

@ -29,6 +29,8 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/certs" "github.com/ProtonMail/proton-bridge/v3/internal/certs"
"github.com/ProtonMail/proton-bridge/v3/internal/events" "github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/services/imapservice" "github.com/ProtonMail/proton-bridge/v3/internal/services/imapservice"
"github.com/ProtonMail/proton-bridge/v3/internal/services/notifications"
"github.com/ProtonMail/proton-bridge/v3/internal/services/observability"
"github.com/ProtonMail/proton-bridge/v3/internal/services/smtp" "github.com/ProtonMail/proton-bridge/v3/internal/services/smtp"
"github.com/ProtonMail/proton-bridge/v3/internal/telemetry/mocks" "github.com/ProtonMail/proton-bridge/v3/internal/telemetry/mocks"
"github.com/ProtonMail/proton-bridge/v3/internal/vault" "github.com/ProtonMail/proton-bridge/v3/internal/vault"
@ -164,8 +166,16 @@ func withUser(tb testing.TB, ctx context.Context, _ *server.Server, m *proton.Ma
nullSMTPServerManager, nullSMTPServerManager,
nullEventSubscription, nullEventSubscription,
nil, nil,
observability.NewService(context.Background(), nil),
"", "",
true, true,
notifications.NewStore(func() (string, error) {
return "", nil
}),
func(_ string) bool {
return false
},
func(_ proton.ObservabilityMetric) {},
) )
require.NoError(tb, err) require.NoError(tb, err)
defer user.Close() defer user.Close()

View File

@ -19,53 +19,57 @@ package vault
import ( import (
"encoding/base64" "encoding/base64"
"encoding/json"
"errors"
"fmt" "fmt"
"io/fs"
"os"
"path/filepath"
"github.com/ProtonMail/gopenpgp/v2/crypto" "github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/ProtonMail/proton-bridge/v3/pkg/keychain" "github.com/ProtonMail/proton-bridge/v3/pkg/keychain"
"github.com/sirupsen/logrus"
) )
const vaultSecretName = "bridge-vault-key" const vaultSecretName = "bridge-vault-key"
type Keychain struct { func GetShouldSkipKeychainTest(vaultDir string) (bool, error) {
Helper string settings, err := LoadKeychainSettings(vaultDir)
}
func getKeychainPrefPath(vaultDir string) string {
return filepath.Clean(filepath.Join(vaultDir, "keychain.json"))
}
func GetHelper(vaultDir string) (string, error) {
if _, err := os.Stat(getKeychainPrefPath(vaultDir)); errors.Is(err, fs.ErrNotExist) {
return "", nil
}
b, err := os.ReadFile(getKeychainPrefPath(vaultDir))
if err != nil { if err != nil {
return "", err return false, err
} }
var keychain Keychain return settings.DisableTest, nil
if err := json.Unmarshal(b, &keychain); err != nil {
return "", err
}
return keychain.Helper, nil
} }
func SetHelper(vaultDir, helper string) error { func SetShouldSkipKeychainTest(vaultDir string, skip bool) error {
b, err := json.MarshalIndent(Keychain{Helper: helper}, "", " ") settings, err := LoadKeychainSettings(vaultDir)
if err != nil { if err != nil {
return err return err
} }
return os.WriteFile(getKeychainPrefPath(vaultDir), b, 0o600) log := logrus.WithFields(logrus.Fields{"pkg": "vault", "skipKeychainTest": skip})
if skip == settings.DisableTest {
log.Info("Skipping change of keychain test setting as value is not modified")
return nil
}
logrus.WithFields(logrus.Fields{"pkg": "vault", "skipKeychainTest": skip}).Info("Setting keychain test skip option")
settings.DisableTest = skip
return settings.Save(vaultDir)
}
func GetHelper(vaultDir string) (string, error) {
settings, err := LoadKeychainSettings(vaultDir)
if err != nil {
return "", err
}
return settings.Helper, nil
}
func SetHelper(vaultDir, helper string) error {
settings, err := LoadKeychainSettings(vaultDir)
if err != nil {
return err
}
settings.Helper = helper
return settings.Save(vaultDir)
} }
func GetVaultKey(kc *keychain.Keychain) ([]byte, error) { func GetVaultKey(kc *keychain.Keychain) ([]byte, error) {

View File

@ -0,0 +1,46 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package vault
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestShouldSkipKeychainTestAccessors(t *testing.T) {
dir := t.TempDir()
skip, err := GetShouldSkipKeychainTest(dir)
require.NoError(t, err)
require.False(t, skip)
require.NoError(t, SetShouldSkipKeychainTest(dir, true))
skip, err = GetShouldSkipKeychainTest(dir)
require.NoError(t, err)
require.True(t, skip)
}
func TestHelperAccessors(t *testing.T) {
dir := t.TempDir()
helper, err := GetHelper(dir)
require.NoError(t, err)
require.Zero(t, len(helper))
require.NoError(t, SetHelper(dir, "keychain"))
helper, err = GetHelper(dir)
require.NoError(t, err)
require.Equal(t, "keychain", helper)
}

View File

@ -0,0 +1,74 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package vault
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"github.com/sirupsen/logrus"
)
const keychainSettingsFileName = "keychain.json"
// KeychainSettings holds settings related to the keychain. It is serialized in the vault directory.
type KeychainSettings struct {
Helper string // The helper used for keychain.
DisableTest bool // Is the keychain test on startup disabled?
}
// LoadKeychainSettings load keychain settings from the vaultDir folder, or returns a default one if the file
// does not exists or is invalid.
func LoadKeychainSettings(vaultDir string) (KeychainSettings, error) {
path := filepath.Join(vaultDir, keychainSettingsFileName)
bytes, err := os.ReadFile(path) //nolint:gosec
if err != nil {
if errors.Is(err, os.ErrNotExist) {
logrus.
WithFields(logrus.Fields{"pkg": "vault", "path": path}).
Trace("Keychain settings file does not exists, default values will be used")
return KeychainSettings{}, nil
}
return KeychainSettings{}, err
}
var result KeychainSettings
if err := json.Unmarshal(bytes, &result); err != nil {
return KeychainSettings{}, fmt.Errorf("keychain settings file is invalid settings: %w", err)
}
return result, nil
}
// Save saves the keychain settings in a file in the vaultDir folder.
func (k KeychainSettings) Save(vaultDir string) error {
bytes, err := json.MarshalIndent(k, "", " ")
if err != nil {
return err
}
if err = os.MkdirAll(vaultDir, 0o700); err != nil {
return err
}
path := filepath.Join(vaultDir, keychainSettingsFileName)
return os.WriteFile(path, bytes, 0o600)
}

View File

@ -0,0 +1,58 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package vault
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestKeychainSettingsIO(t *testing.T) {
dir := t.TempDir()
// test loading non existing file. no error but loads defaults.
settings, err := LoadKeychainSettings(dir)
require.NoError(t, err)
require.Equal(t, settings, KeychainSettings{})
// test file creation
settings.Helper = "dummy1"
settings.DisableTest = true
require.NoError(t, settings.Save(dir))
// test reading existing file
readBack, err := LoadKeychainSettings(dir)
require.NoError(t, err)
require.Equal(t, settings, readBack)
// test file overwrite and read back
settings.Helper = "dummy2"
require.NoError(t, settings.Save(dir))
readBack, err = LoadKeychainSettings(dir)
require.NoError(t, err)
require.Equal(t, settings, readBack)
// test error on invalid content
settingsFilePath := filepath.Join(dir, keychainSettingsFileName)
require.NoError(t, os.WriteFile(settingsFilePath, []byte("][INVALID"), 0o600))
_, err = LoadKeychainSettings(dir)
require.Error(t, err)
}

View File

@ -27,7 +27,7 @@ import (
func TestUser_New(t *testing.T) { func TestUser_New(t *testing.T) {
// Replace the token generator with a dummy one. // Replace the token generator with a dummy one.
vault.RandomToken = func(size int) ([]byte, error) { vault.RandomToken = func(_ int) ([]byte, error) {
return []byte("token"), nil return []byte("token"), nil
} }
@ -243,7 +243,7 @@ func TestUser_ForEach(t *testing.T) {
func TestUser_ShouldResync(t *testing.T) { func TestUser_ShouldResync(t *testing.T) {
// Replace the token generator with a dummy one. // Replace the token generator with a dummy one.
vault.RandomToken = func(size int) ([]byte, error) { vault.RandomToken = func(_ int) ([]byte, error) {
return []byte("token"), nil return []byte("token"), nil
} }

View File

@ -56,7 +56,7 @@ func (op *OpRemove) Do() error {
func remove(dir string, except ...string) error { func remove(dir string, except ...string) error {
var toRemove []string var toRemove []string
if err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err := filepath.Walk(dir, func(path string, _ os.FileInfo, _ error) error {
for _, exception := range except { for _, exception := range except {
if path == exception || strings.HasPrefix(exception, path) || strings.HasPrefix(path, exception) { if path == exception || strings.HasPrefix(exception, path) || strings.HasPrefix(path, exception) {
return nil return nil

View File

@ -31,15 +31,20 @@ const (
MacOSKeychain = "macos-keychain" MacOSKeychain = "macos-keychain"
) )
func listHelpers() (Helpers, string) { func listHelpers(skipKeychainTest bool) (Helpers, string) {
helpers := make(Helpers) helpers := make(Helpers)
// MacOS always provides a keychain. // MacOS always provides a keychain.
if isUsable(newMacOSHelper("")) { if skipKeychainTest {
logrus.WithField("pkg", "keychain").Info("Skipping macOS keychain test")
helpers[MacOSKeychain] = newMacOSHelper helpers[MacOSKeychain] = newMacOSHelper
logrus.WithField("keychain", "MacOSKeychain").Info("Keychain is usable.")
} else { } else {
logrus.WithField("keychain", "MacOSKeychain").Debug("Keychain is not available.") if isUsable(newMacOSHelper("")) {
helpers[MacOSKeychain] = newMacOSHelper
logrus.WithField("keychain", "MacOSKeychain").Info("Keychain is usable.")
} else {
logrus.WithField("keychain", "MacOSKeychain").Debug("Keychain is not available.")
}
} }
// Use MacOSKeychain by default. // Use MacOSKeychain by default.

View File

@ -31,7 +31,7 @@ const (
SecretServiceDBus = "secret-service-dbus" SecretServiceDBus = "secret-service-dbus"
) )
func listHelpers() (Helpers, string) { func listHelpers(_ bool) (Helpers, string) {
helpers := make(Helpers) helpers := make(Helpers)
if isUsable(newDBusHelper("")) { if isUsable(newDBusHelper("")) {

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