Add email notifications when a guest link has been created

This commit is contained in:
André Duffeck committed 2026-10-06 12:46:01 +02:00
1 parent 27a0195146
commit ec7f3f68a4
5 files changed
+211 -13

No files matched your search

@@ -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 {
@@ -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,
@@ -0,0 +1,84 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// 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})
}
@@ -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
}
@@ -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: `<!DOCTYPE html>
<html>
<body>
<table cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td>
<table cellspacing="0" cellpadding="0" border="0" width="600px">
<tr>
<td width="20px">&nbsp;</td>
<td style="font-weight:normal; font-size:0.8em; line-height:1.2em; font-family:verdana,'arial',sans;">
Hello,
<br><br>
Dr. S. Harer has shared "guest folder" with you.<br><br>The link below is personal and can only be used once. Please do not forward it.<br><br>The link is only valid for 30 minutes.
<br><br>
Click here to view it: <a href="https://cloud.example.com/g/v1.sharehash.secret">https://cloud.example.com/g/v1.sharehash.secret</a>
</td>
</tr>
<tr>
<td colspan="2">&nbsp;</td>
</tr>
<tr>
<td width="20px">&nbsp;</td>
<td style="font-weight:normal; font-size:0.8em; line-height:1.2em; font-family:verdana,'arial',sans;">
<footer>
<br>
<br>
--- <br>
OpenCloud - a safe home for all your data<br>
<a href="https://opencloud.eu">https://opencloud.eu</a>
</footer>
</td>
</tr>
<tr>
<td colspan="2">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`,
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 "<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
---
@@ -380,7 +457,7 @@ https://opencloud.eu
<br><br>
Dr. O&#39;reilly has shared "&lt;script&gt;alert(&#39;secrets of the board&#39;);&lt;/script&gt;" with you.
<br><br>
Click here to view it: <a href="files/shares/with-me">files/shares/with-me</a>
Click here to view it: <a href="https://cloud.example.com/files/shares/with-me">https://cloud.example.com/files/shares/with-me</a>
</td>
</tr>
<tr>
@@ -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 "<script>alert('secret space');</script>".
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
<br><br>
Dr. O&#39;reilly has invited you to join "&lt;script&gt;alert(&#39;secret space&#39;);&lt;/script&gt;".
<br><br>
Click here to view it: <a href="f/spaceid">f/spaceid</a>
Click here to view it: <a href="https://cloud.example.com/f/spaceid">https://cloud.example.com/f/spaceid</a>
</td>
</tr>
<tr>
@@ -477,7 +555,8 @@ https://opencloud.eu
</body>
</html>
`,
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(`<a href="` + tc.expectedLink + `">`))
}
tc.done <- struct{}{}
return nil
}