feat(BRIDGE-37): Remote notification support

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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