mirror of
https://github.com/ProtonMail/proton-bridge.git
synced 2025-12-10 04:36:43 +00:00
feat(BRIDGE-37): Remote notification support
This commit is contained in:
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
|
||||
)
|
||||
|
||||
type PushObsMetricFn func(metric proton.ObservabilityMetric)
|
||||
|
||||
type client struct {
|
||||
isTelemetryEnabled func(context.Context) bool
|
||||
sendMetrics func(context.Context, proton.ObservabilityBatch) error
|
||||
|
||||
@ -38,6 +38,7 @@ type EventHandler struct {
|
||||
MessageHandler MessageEventHandler
|
||||
UsedSpaceHandler UserUsedSpaceEventHandler
|
||||
UserSettingsHandler UserSettingsHandler
|
||||
NotificationHandler NotificationEventHandler
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@ -116,3 +123,7 @@ type LabelEventHandler interface {
|
||||
type MessageEventHandler interface {
|
||||
HandleMessageEvents(ctx context.Context, events []proton.MessageEvent) error
|
||||
}
|
||||
|
||||
type NotificationEventHandler interface {
|
||||
HandleNotificationEvents(ctx context.Context, events []proton.NotificationEvent) error
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user