feat(BRIDGE-37): Remote notification support

This commit is contained in:
Atanas Janeshliev
2024-08-29 13:31:37 +02:00
parent ed1b65731a
commit f04350c046
43 changed files with 2350 additions and 1168 deletions

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.20240827122236-ca6bb6449bba 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

4
go.sum
View File

@ -52,6 +52,10 @@ github.com/ProtonMail/go-proton-api v0.4.1-0.20240827084449-71096377c391 h1:PW6b
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.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 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.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

@ -45,6 +45,7 @@ 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/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"
@ -144,6 +145,9 @@ type Bridge struct {
// observabilityService is responsible for handling calls to the observability system // observabilityService is responsible for handling calls to the observability system
observabilityService *observability.Service 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
@ -307,6 +311,8 @@ func newBridge(
unleashService: unleashService, unleashService: unleashService,
observabilityService: observability.NewService(ctx, panicHandler), observabilityService: observability.NewService(ctx, panicHandler),
notificationStore: notifications.NewStore(locator.ProvideNotificationsCachePath),
} }
bridge.serverManager = imapsmtpserver.NewService(context.Background(), bridge.serverManager = imapsmtpserver.NewService(context.Background(),

View File

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

View File

@ -569,6 +569,9 @@ func (bridge *Bridge) addUserWithVault(
bridge.observabilityService, 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

@ -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,6 +49,9 @@ 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.
@ -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">
@ -30,6 +36,27 @@
</attribute> </attribute>
</widget> </widget>
</item> </item>
<item>
<widget class="QScrollArea" name="scrollArea">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="widgetResizable">
<bool>true</bool>
</property>
<widget class="QWidget" name="scrollAreaWidgetContents">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>327</width>
<height>905</height>
</rect>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_6">
<item> <item>
<layout class="QVBoxLayout" name="verticalLayout"> <layout class="QVBoxLayout" name="verticalLayout">
<item> <item>
@ -66,6 +93,68 @@
</property> </property>
</spacer> </spacer>
</item> </item>
<item>
<widget class="QGroupBox" name="groupBoxNotification">
<property name="enabled">
<bool>true</bool>
</property>
<property name="minimumSize">
<size>
<width>300</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>300</width>
<height>400</height>
</size>
</property>
<property name="title">
<string>Notification</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_9">
<item>
<layout class="QVBoxLayout" name="verticalLayout_6" stretch="0,0,0,0">
<item>
<widget class="QLineEdit" name="notificationTitle">
<property name="placeholderText">
<string>Title</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="notificationSubtitleText">
<property name="minimumSize">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
<property name="placeholderText">
<string>Subtitle</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="notticationBodyText">
<property name="placeholderText">
<string>Body</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="sendNotificationButton">
<property name="text">
<string>Send</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
<item> <item>
<widget class="QGroupBox" name="groupboxSync"> <widget class="QGroupBox" name="groupboxSync">
<property name="minimumSize"> <property name="minimumSize">
@ -240,7 +329,7 @@
<property name="minimumSize"> <property name="minimumSize">
<size> <size>
<width>0</width> <width>0</width>
<height>100</height> <height>250</height>
</size> </size>
</property> </property>
<property name="title"> <property name="title">
@ -360,6 +449,10 @@
</item> </item>
</layout> </layout>
</widget> </widget>
</widget>
</item>
</layout>
</widget>
<tabstops> <tabstops>
<tabstop>buttonNewUser</tabstop> <tabstop>buttonNewUser</tabstop>
<tabstop>buttonEditUser</tabstop> <tabstop>buttonEditUser</tabstop>

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>

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

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

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.")
@ -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,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))
} }
} }
} }

View File

@ -248,6 +248,10 @@ 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")
} }
@ -276,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

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

@ -36,6 +36,8 @@ const (
maxBatchSize = 1000 maxBatchSize = 1000
) )
type PushObsMetricFn func(metric proton.ObservabilityMetric)
type client struct { type client struct {
isTelemetryEnabled func(context.Context) bool isTelemetryEnabled func(context.Context) bool
sendMetrics func(context.Context, proton.ObservabilityBatch) error sendMetrics func(context.Context, proton.ObservabilityBatch) error

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

@ -37,6 +37,7 @@ var pollJitter = 2 * time.Minute //nolint:gochecknoglobals
const filename = "unleash_flags" const filename = "unleash_flags"
type requestFeaturesFn func(ctx context.Context) (proton.FeatureFlagResult, error) type requestFeaturesFn func(ctx context.Context) (proton.FeatureFlagResult, error)
type GetFlagValueFn func(key string) bool
type Service struct { type Service struct {
panicHandler async.PanicHandler panicHandler async.PanicHandler

View File

@ -31,6 +31,7 @@ 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/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"
@ -40,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"
@ -86,6 +88,7 @@ type User struct {
smtpService *smtp.Service smtpService *smtp.Service
imapService *imapservice.Service imapService *imapservice.Service
telemetryService *telemetryservice.Service telemetryService *telemetryservice.Service
notificationService *notifications.Service
observabilityService *observability.Service observabilityService *observability.Service
@ -110,6 +113,9 @@ func New(
observabilityService *observability.Service, 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,
@ -129,6 +135,9 @@ func New(
observabilityService, 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
@ -161,6 +170,9 @@ func newImpl(
observabilityService *observability.Service, 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")
@ -278,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)
@ -329,6 +343,9 @@ func newImpl(
// Add user client to observability service // Add user client to observability service
observabilityService.RegisterUserClient(user.id, client, user.telemetryService) 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)

View File

@ -29,6 +29,7 @@ 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/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"
@ -168,6 +169,13 @@ func withUser(tb testing.TB, ctx context.Context, _ *server.Server, m *proton.Ma
observability.NewService(context.Background(), 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

@ -0,0 +1,17 @@
Feature: Bridge send remote notification observability metrics
Background:
Given there exists an account with username "[user:user1]" and password "password"
And there exists an account with username "[user:user2]" and password "password"
Then it succeeds
When bridge starts
Then it succeeds
Scenario: Send notification 'received' and 'processed' observability metric
When the user logs in with username "[user:user1]" and password "password"
And the user with username "[user:user1]" sends the following remote notification observability metric "received"
Then it succeeds
And the user with username "[user:user1]" sends the following remote notification observability metric "processed"
Then it succeeds

View File

@ -216,4 +216,8 @@ func (s *scenario) steps(ctx *godog.ScenarioContext) {
ctx.Step(`^the contact "([^"]*)" of user "([^"]*)" has encryption "([^"]*)"$`, s.contactOfUserHasEncryption) ctx.Step(`^the contact "([^"]*)" of user "([^"]*)" has encryption "([^"]*)"$`, s.contactOfUserHasEncryption)
ctx.Step(`^the contact "([^"]*)" of user "([^"]*)" has public key:$`, s.contactOfUserHasPubKey) ctx.Step(`^the contact "([^"]*)" of user "([^"]*)" has public key:$`, s.contactOfUserHasPubKey)
ctx.Step(`^the contact "([^"]*)" of user "([^"]*)" has public key from file "([^"]*)"$`, s.contactOfUserHasPubKeyFromFile) ctx.Step(`^the contact "([^"]*)" of user "([^"]*)" has public key from file "([^"]*)"$`, s.contactOfUserHasPubKeyFromFile)
// ==== OBSERVABILITY METRICS ====
ctx.Step(`^the user with username "([^"]*)" sends the following remote notification observability metric "([^"]*)"`,
s.userRemoteNotificationMetricTest)
} }

View File

@ -23,11 +23,13 @@ import (
"fmt" "fmt"
"net/mail" "net/mail"
"strings" "strings"
"time"
"github.com/ProtonMail/gluon/rfc822" "github.com/ProtonMail/gluon/rfc822"
"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/ProtonMail/proton-bridge/v3/internal/bridge" "github.com/ProtonMail/proton-bridge/v3/internal/bridge"
"github.com/ProtonMail/proton-bridge/v3/internal/services/notifications"
"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"
"github.com/bradenaw/juniper/iterator" "github.com/bradenaw/juniper/iterator"
@ -690,3 +692,24 @@ func matchSettings(have proton.MailSettings, want MailSettings) error {
return nil return nil
} }
func (s *scenario) userRemoteNotificationMetricTest(username string, metricName string) error {
var metricToTest proton.ObservabilityMetric
switch strings.ToLower(metricName) {
case "processed":
metricToTest = notifications.GenerateProcessedMetric(1)
case "received":
metricToTest = notifications.GenerateReceivedMetric(1)
default:
return fmt.Errorf("invalid metric name specified")
}
// Account for endpoint throttle
time.Sleep(time.Second * 5)
return s.t.withClientPass(context.Background(), username, s.t.getUserByName(username).userPass, func(ctx context.Context, c *proton.Client) error {
batch := proton.ObservabilityBatch{Metrics: []proton.ObservabilityMetric{metricToTest}}
err := c.SendObservabilityBatch(ctx, batch)
return err
})
}