diff --git a/services/notifications/pkg/command/server.go b/services/notifications/pkg/command/server.go index f1fbfa4d78..b2d4df6a64 100644 --- a/services/notifications/pkg/command/server.go +++ b/services/notifications/pkg/command/server.go @@ -91,6 +91,7 @@ func Server(cfg *config.Config) *cobra.Command { events.ScienceMeshInviteTokenGenerated{}, events.SendEmailsEvent{}, ocEvents.ResourceMention{}, + ocEvents.GuestTokenCreated{}, } registeredEvents := make(map[string]events.Unmarshaller) for _, e := range evs { diff --git a/services/notifications/pkg/email/templates.go b/services/notifications/pkg/email/templates.go index 5ab5d813c1..e4432171b1 100644 --- a/services/notifications/pkg/email/templates.go +++ b/services/notifications/pkg/email/templates.go @@ -49,6 +49,24 @@ Even though this share has been revoked you still might have access through othe Even though this share has been revoked you still might have access through other shares and/or space memberships.`), } + // Guest link templates + GuestLinkShareCreated = MessageTemplate{ + textTemplate: _textTemplate, + htmlTemplate: _htmlTemplate, + // GuestLinkShareCreated email template, Subject field (resolves directly) + Subject: l10n.Template(`{ShareSharer} shared '{ShareFolder}' with you`), + // GuestLinkShareCreated email template, resolves via {{ .Greeting }} + Greeting: l10n.Template(`Hello,`), + // GuestLinkShareCreated email template, resolves via {{ .MessageBody }} + MessageBody: l10n.Template(`{ShareSharer} has shared "{ShareFolder}" with you. + +The link below is personal and can only be used once. Please do not forward it. + +The link is only valid for 30 minutes.`), + // GuestLinkShareCreated email template, resolves via {{ .CallToAction }} + CallToAction: l10n.Template(`Click here to view it: {ShareLink}`), + } + // Spaces templates SharedSpace = MessageTemplate{ textTemplate: _textTemplate, diff --git a/services/notifications/pkg/service/guestlinks.go b/services/notifications/pkg/service/guestlinks.go new file mode 100644 index 0000000000..7f719fc17f --- /dev/null +++ b/services/notifications/pkg/service/guestlinks.go @@ -0,0 +1,84 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "context" + + "github.com/opencloud-eu/reva/v2/pkg/utils" + "google.golang.org/protobuf/types/known/fieldmaskpb" + + ocevents "github.com/opencloud-eu/opencloud/pkg/events" + "github.com/opencloud-eu/opencloud/services/notifications/pkg/channels" + "github.com/opencloud-eu/opencloud/services/notifications/pkg/email" +) + +// handleGuestTokenCreated sends the guest link to the guest's email address. +// Guests have no account, settings or locale, so the mail is always sent +// instantly (the token is short-lived) in the default language. +func (s eventsNotifier) handleGuestTokenCreated(e ocevents.GuestTokenCreated) { + logger := s.logger.With(). + Str("event", "GuestTokenCreated"). + Str("itemid", e.ItemID.GetOpaqueId()). + Logger() + + if err := validate.Var(e.GranteeEmail, "required,email"); err != nil { + logger.Error().Err(err).Msg("invalid guest email address") + return + } + if e.Token == "" { + logger.Error().Msg("guest token is empty") + return + } + + gatewayClient, err := s.gatewaySelector.Next() + if err != nil { + logger.Error().Err(err).Msg("could not select next gateway client") + return + } + + ctx, err := utils.GetServiceUserContextWithContext(context.Background(), gatewayClient, s.serviceAccountID, s.serviceAccountSecret) + if err != nil { + logger.Error().Err(err).Msg("could not get service user context") + return + } + + owner, err := utils.GetUserNoGroups(ctx, e.Sharer, gatewayClient) + if err != nil { + logger.Error().Err(err).Msg("could not get user") + return + } + + shareFolder := e.ResourceName + if shareFolder == "" { + resourceInfo, err := s.getResourceInfo(ctx, e.ItemID, &fieldmaskpb.FieldMask{Paths: []string{"name"}}) + if err != nil { + logger.Error().Err(err).Msg("could not stat resource") + return + } + shareFolder = resourceInfo.GetName() + } + + shareLink, err := urlJoinPath(s.openCloudURL, "g", e.Token) + if err != nil { + logger.Error().Err(err).Msg("could not create guest link") + return + } + + msg, err := email.RenderEmailTemplate(email.GuestLinkShareCreated, s.defaultLanguage, s.defaultLanguage, + s.emailTemplatePath, s.translationPath, + map[string]string{ + "ShareSharer": owner.GetDisplayName(), + "ShareFolder": shareFolder, + "ShareLink": shareLink, + }) + if err != nil { + logger.Error().Err(err).Msg("could not render the email") + return + } + msg.Sender = owner.GetDisplayName() + msg.Recipient = []string{e.GranteeEmail} + + s.send(ctx, []*channels.Message{msg}) +} diff --git a/services/notifications/pkg/service/service.go b/services/notifications/pkg/service/service.go index 74b2e491e8..f4c916d587 100644 --- a/services/notifications/pkg/service/service.go +++ b/services/notifications/pkg/service/service.go @@ -131,6 +131,8 @@ EventLoop: s.handleShareExpired(e, evt.ID) case events.ShareRemoved: s.handleShareRemoved(e, evt.ID) + case ocEvents.GuestTokenCreated: + s.handleGuestTokenCreated(e) case events.ScienceMeshInviteTokenGenerated: s.handleScienceMeshInviteTokenGenerated(e) case events.SendEmailsEvent: @@ -202,6 +204,10 @@ func (s eventsNotifier) ensureGranteeList(ctx context.Context, executant, u *use func (s eventsNotifier) getGranteeList(ctx context.Context, executant, u *user.UserId, g *group.GroupId) ([]*user.User, error) { switch { case u != nil: + // guests aren't users, they are notified via GuestTokenCreated + if u.GetType() == user.UserType_USER_TYPE_GUEST { + return nil, nil + } if s.disableEmails(ctx, u) { return nil, nil } diff --git a/services/notifications/pkg/service/service_test.go b/services/notifications/pkg/service/service_test.go index 0605b9ba76..54f0221284 100644 --- a/services/notifications/pkg/service/service_test.go +++ b/services/notifications/pkg/service/service_test.go @@ -13,6 +13,7 @@ import ( provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + ocEvents "github.com/opencloud-eu/opencloud/pkg/events" "github.com/opencloud-eu/opencloud/pkg/log" "github.com/opencloud-eu/opencloud/pkg/shared" settingssvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/settings/v0" @@ -98,7 +99,7 @@ var _ = Describe("Notifications", func() { cfg.GRPCClientTLS = &shared.GRPCClientTLS{} ch := make(chan events.Event) evts := service.NewEventsNotifier(ch, tc, log.NewLogger(), gatewaySelector, vs, "", - "", "", "", "", "", "", + "", "", "", testOpenCloudURL, "", "", store.Create(), nil, nil) go evts.Run() @@ -118,7 +119,7 @@ var _ = Describe("Notifications", func() { Dr. S. Harer has shared "secrets of the board" with you. -Click here to view it: files/shares/with-me +Click here to view it: https://cloud.example.com/files/shares/with-me --- @@ -126,6 +127,7 @@ OpenCloud - a safe home for all your data https://opencloud.eu `, expectedSender: sharer.GetDisplayName(), + expectedLink: testOpenCloudURL + "/files/shares/with-me", done: make(chan struct{}), }, events.Event{ Event: events.ShareCreated{ @@ -192,7 +194,7 @@ https://opencloud.eu Dr. S. Harer has invited you to join "secret space". -Click here to view it: f/spaceid +Click here to view it: https://cloud.example.com/f/spaceid --- @@ -200,6 +202,7 @@ OpenCloud - a safe home for all your data https://opencloud.eu `, expectedSender: sharer.GetDisplayName(), + expectedLink: testOpenCloudURL + "/f/spaceid", done: make(chan struct{}), }, events.Event{ Event: events.SpaceShared{ @@ -219,7 +222,7 @@ Dr. S. Harer has removed you from "secret space". You might still have access through your other groups or direct membership. -Click here to check it: f/spaceid +Click here to check it: https://cloud.example.com/f/spaceid --- @@ -227,6 +230,7 @@ OpenCloud - a safe home for all your data https://opencloud.eu `, expectedSender: sharer.GetDisplayName(), + expectedLink: testOpenCloudURL + "/f/spaceid", done: make(chan struct{}), }, events.Event{ Event: events.SpaceUnshared{ @@ -261,6 +265,79 @@ https://opencloud.eu ExpiredAt: time.Date(2023, 4, 17, 16, 42, 0, 0, time.UTC), }, }), + + Entry("Guest Token Created", testChannel{ + expectedReceipients: []string{"guest@example.com"}, + expectedSubject: "Dr. S. Harer shared 'guest folder' with you", + expectedTextBody: `Hello, + +Dr. S. Harer has shared "guest folder" with you. + +The link below is personal and can only be used once. Please do not forward it. + +The link is only valid for 30 minutes. + +Click here to view it: https://cloud.example.com/g/v1.sharehash.secret + + +--- +OpenCloud - a safe home for all your data +https://opencloud.eu +`, + expectedHTMLBody: ` + + + + + + +
+ + + + + + + + + + + + + + + +
  + Hello, +

+ Dr. S. Harer has shared "guest folder" with you.

The link below is personal and can only be used once. Please do not forward it.

The link is only valid for 30 minutes. +

+ Click here to view it: https://cloud.example.com/g/v1.sharehash.secret +
 
  + +
 
+
+ + +`, + expectedSender: sharer.GetDisplayName(), + expectedLink: testOpenCloudURL + "/g/v1.sharehash.secret", + done: make(chan struct{}), + }, events.Event{ + Event: ocEvents.GuestTokenCreated{ + Sharer: sharer.GetId(), + GranteeEmail: "guest@example.com", + ItemID: resourceid, + ResourceName: "guest folder", + Token: "v1.sharehash.secret", + }, + }), ) }) @@ -339,7 +416,7 @@ var _ = Describe("Notifications X-Site Scripting", func() { cfg.GRPCClientTLS = &shared.GRPCClientTLS{} ch := make(chan events.Event) evts := service.NewEventsNotifier(ch, tc, log.NewLogger(), gatewaySelector, vs, "", - "", "", "", "", "", "", + "", "", "", testOpenCloudURL, "", "", store.Create(), nil, nil) go evts.Run() @@ -359,7 +436,7 @@ var _ = Describe("Notifications X-Site Scripting", func() { Dr. O'reilly has shared "" with you. -Click here to view it: files/shares/with-me +Click here to view it: https://cloud.example.com/files/shares/with-me --- @@ -380,7 +457,7 @@ https://opencloud.eu

Dr. O'reilly has shared "<script>alert('secrets of the board');</script>" with you.

- Click here to view it: files/shares/with-me + Click here to view it: https://cloud.example.com/files/shares/with-me @@ -410,7 +487,8 @@ https://opencloud.eu `, expectedSender: sharer.GetDisplayName(), - done: make(chan struct{}), + expectedLink: testOpenCloudURL + "/files/shares/with-me", + done: make(chan struct{}), }, events.Event{ Event: events.ShareCreated{ Sharer: sharer.GetId(), @@ -427,7 +505,7 @@ https://opencloud.eu Dr. O'reilly has invited you to join "". -Click here to view it: f/spaceid +Click here to view it: https://cloud.example.com/f/spaceid --- @@ -449,7 +527,7 @@ https://opencloud.eu

Dr. O'reilly has invited you to join "<script>alert('secret space');</script>".

- Click here to view it: f/spaceid + Click here to view it: https://cloud.example.com/f/spaceid @@ -477,7 +555,8 @@ https://opencloud.eu `, - done: make(chan struct{}), + expectedLink: testOpenCloudURL + "/f/spaceid", + done: make(chan struct{}), }, events.Event{ Event: events.SpaceShared{ Executant: sharer.GetId(), @@ -489,14 +568,18 @@ https://opencloud.eu ) }) -// NOTE: This is explictitly not testing the message itself. Should we? +const testOpenCloudURL = "https://cloud.example.com" + type testChannel struct { expectedReceipients []string expectedSubject string expectedTextBody string expectedHTMLBody string expectedSender string - done chan struct{} + // expectedLink, if set, must be an absolute URL on the OpenCloud instance + // and must be linked in the HTML body. + expectedLink string + done chan struct{} } func (tc testChannel) SendMessage(ctx context.Context, m *channels.Message) error { @@ -506,9 +589,15 @@ func (tc testChannel) SendMessage(ctx context.Context, m *channels.Message) erro Expect(tc.expectedSubject).To(Equal(m.Subject)) Expect(tc.expectedTextBody).To(Equal(m.TextBody)) Expect(tc.expectedSender).To(Equal(m.Sender)) + Expect(m.HTMLBody).ToNot(BeEmpty()) if tc.expectedHTMLBody != "" { Expect(tc.expectedHTMLBody).To(Equal(m.HTMLBody)) } + if tc.expectedLink != "" { + Expect(tc.expectedLink).To(HavePrefix(testOpenCloudURL + "/")) + Expect(m.TextBody).To(ContainSubstring(tc.expectedLink)) + Expect(m.HTMLBody).To(ContainSubstring(``)) + } tc.done <- struct{}{} return nil }