forked from Silverfish/proton-bridge
feat(BRIDGE-37): Remote notification support
This commit is contained in:
2
go.mod
2
go.mod
@ -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
4
go.sum
@ -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=
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 |
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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.");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
@ -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
|
||||||
//**********************************************************
|
//**********************************************************
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
51
internal/services/notifications/metrics.go
Normal file
51
internal/services/notifications/metrics.go
Normal 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)
|
||||||
|
}
|
||||||
112
internal/services/notifications/notification_test.go
Normal file
112
internal/services/notifications/notification_test.go
Normal 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))
|
||||||
|
}
|
||||||
156
internal/services/notifications/service.go
Normal file
156
internal/services/notifications/service.go
Normal 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
|
||||||
|
}
|
||||||
161
internal/services/notifications/store.go
Normal file
161
internal/services/notifications/store.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
17
tests/features/observability/remote_notification.feature
Normal file
17
tests/features/observability/remote_notification.feature
Normal 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
|
||||||
|
|
||||||
|
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user