Merge pull request #3723 from aduffeck/send-guestlink-renewal-emails

Add email notifications when a guest link has been renewed
This commit is contained in:
Andre Duffeck authored and GitHub committed 2026-10-15 13:43:49 +02:00
commit a303d7ff84
6 files changed
+106 -16

No files matched your search

+4 -2
View File
@@ -18,7 +18,9 @@ links feature and start the service.
- **Consumes** the share lifecycle events `ShareCreated`, `ShareRemoved` and
`ShareExpired`.
- **Publishes** the `GuestTokenCreated` event (initial token) and the
`GuestTokenRenewed` event (refresh, carrying the new token and the PIN).
`GuestTokenRenewed` event (refresh, carrying the new token and the PIN). The
`notifications` service consumes both and mails the link (and the PIN) to
the guest.
- Exposes unauthenticated endpoints to redeem a token, renew it and verify a
PIN; redeeming a token or verifying a PIN sets a session cookie.
- Stores only hashes of the token secret and of the PIN and deletes the stored
@@ -111,7 +113,7 @@ sequenceDiagram
Web->>+Renew: POST renew { permissionId } (+ session cookie or link token)
Note right of Renew: Validate share, generate token + PIN, store hashes
Renew->>Bus: publish GuestTokenRenewed
Note right of Bus: External consumer delivers token + PIN (out of scope)
Note right of Bus: notifications service mails the new link + PIN to the guest
Renew->>-Web: 200 OK
alt Guest opens the new link
@@ -92,6 +92,7 @@ func Server(cfg *config.Config) *cobra.Command {
events.SendEmailsEvent{},
ocEvents.ResourceMention{},
ocEvents.GuestTokenCreated{},
ocEvents.GuestTokenRenewed{},
}
registeredEvents := make(map[string]events.Unmarshaller)
for _, e := range evs {
@@ -67,6 +67,25 @@ The link is only valid for 30 minutes.`),
CallToAction: l10n.Template(`Click here to view it: {ShareLink}`),
}
GuestLinkShareRenewed = MessageTemplate{
textTemplate: _textTemplate,
htmlTemplate: _htmlTemplate,
// GuestLinkShareRenewed email template, Subject field (resolves directly)
Subject: l10n.Template(`Your new link to '{ShareFolder}'`),
// GuestLinkShareRenewed email template, resolves via {{ .Greeting }}
Greeting: l10n.Template(`Hello,`),
// GuestLinkShareRenewed email template, resolves via {{ .MessageBody }}
MessageBody: l10n.Template(`A new link to "{ShareFolder}", shared with you by {ShareSharer}, has been requested.
Your PIN: {SharePin}
Open the link below or enter the PIN on the page where you requested the new link. The link and the PIN are personal and can only be used once. Please do not forward them.
The link and the PIN are only valid for 30 minutes.`),
// GuestLinkShareRenewed email template, resolves via {{ .CallToAction }}
CallToAction: l10n.Template(`Click here to view it: {ShareLink}`),
}
// Spaces templates
SharedSpace = MessageTemplate{
textTemplate: _textTemplate,
@@ -166,6 +185,7 @@ var _placeholders = map[string]string{
"{ShareFolder}": "{{ .ShareFolder }}",
"{ShareGrantee}": "{{ .ShareGrantee }}",
"{ShareLink}": "{{ .ShareLink }}",
"{SharePin}": "{{ .SharePin }}",
"{SpaceName}": "{{ .SpaceName }}",
"{SpaceGrantee}": "{{ .SpaceGrantee }}",
"{SpaceSharer}": "{{ .SpaceSharer }}",
@@ -6,7 +6,10 @@ package service
import (
"context"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"github.com/rs/zerolog"
"google.golang.org/protobuf/types/known/fieldmaskpb"
ocevents "github.com/opencloud-eu/opencloud/pkg/events"
@@ -23,11 +26,36 @@ func (s eventsNotifier) handleGuestTokenCreated(e ocevents.GuestTokenCreated) {
Str("itemid", e.ItemID.GetOpaqueId()).
Logger()
if err := validate.Var(e.GranteeEmail, "required,email"); err != nil {
s.sendGuestLinkMail(logger, email.GuestLinkShareCreated, e.Sharer, e.GranteeEmail, e.ItemID, e.ResourceName, e.Token, nil)
}
// handleGuestTokenRenewed sends the renewed guest link and the PIN to the
// guest's email address, like handleGuestTokenCreated.
func (s eventsNotifier) handleGuestTokenRenewed(e ocevents.GuestTokenRenewed) {
logger := s.logger.With().
Str("event", "GuestTokenRenewed").
Str("itemid", e.ItemID.GetOpaqueId()).
Logger()
if e.Pin == "" {
logger.Error().Msg("guest pin is empty")
return
}
s.sendGuestLinkMail(logger, email.GuestLinkShareRenewed, e.Sharer, e.GranteeEmail, e.ItemID, "", e.Token,
map[string]string{"SharePin": e.Pin})
}
// sendGuestLinkMail renders the guest link template and sends it to the guest.
// The resource is stat'ed when resourceName is empty. vars are passed to the
// template in addition to ShareSharer, ShareFolder and ShareLink.
func (s eventsNotifier) sendGuestLinkMail(logger zerolog.Logger, template email.MessageTemplate, sharer *user.UserId,
granteeEmail string, itemID *provider.ResourceId, resourceName, token string, vars map[string]string) {
if err := validate.Var(granteeEmail, "required,email"); err != nil {
logger.Error().Err(err).Msg("invalid guest email address")
return
}
if e.Token == "" {
if token == "" {
logger.Error().Msg("guest token is empty")
return
}
@@ -44,15 +72,15 @@ func (s eventsNotifier) handleGuestTokenCreated(e ocevents.GuestTokenCreated) {
return
}
owner, err := utils.GetUserNoGroups(ctx, e.Sharer, gatewayClient)
owner, err := utils.GetUserNoGroups(ctx, sharer, gatewayClient)
if err != nil {
logger.Error().Err(err).Msg("could not get user")
return
}
shareFolder := e.ResourceName
shareFolder := resourceName
if shareFolder == "" {
resourceInfo, err := s.getResourceInfo(ctx, e.ItemID, &fieldmaskpb.FieldMask{Paths: []string{"name"}})
resourceInfo, err := s.getResourceInfo(ctx, itemID, &fieldmaskpb.FieldMask{Paths: []string{"name"}})
if err != nil {
logger.Error().Err(err).Msg("could not stat resource")
return
@@ -60,25 +88,29 @@ func (s eventsNotifier) handleGuestTokenCreated(e ocevents.GuestTokenCreated) {
shareFolder = resourceInfo.GetName()
}
shareLink, err := urlJoinPath(s.openCloudURL, "g", e.Token)
shareLink, err := urlJoinPath(s.openCloudURL, "g", 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,
})
templateVars := map[string]string{
"ShareSharer": owner.GetDisplayName(),
"ShareFolder": shareFolder,
"ShareLink": shareLink,
}
for k, v := range vars {
templateVars[k] = v
}
msg, err := email.RenderEmailTemplate(template, s.defaultLanguage, s.defaultLanguage,
s.emailTemplatePath, s.translationPath, templateVars)
if err != nil {
logger.Error().Err(err).Msg("could not render the email")
return
}
msg.Sender = owner.GetDisplayName()
msg.Recipient = []string{e.GranteeEmail}
msg.Recipient = []string{granteeEmail}
s.send(ctx, []*channels.Message{msg})
}
@@ -133,6 +133,8 @@ EventLoop:
s.handleShareRemoved(e, evt.ID)
case ocEvents.GuestTokenCreated:
s.handleGuestTokenCreated(e)
case ocEvents.GuestTokenRenewed:
s.handleGuestTokenRenewed(e)
case events.ScienceMeshInviteTokenGenerated:
s.handleScienceMeshInviteTokenGenerated(e)
case events.SendEmailsEvent:
@@ -338,6 +338,39 @@ https://opencloud.eu
Token: "v1.sharehash.secret",
},
}),
Entry("Guest Token Renewed", testChannel{
expectedReceipients: []string{"guest@example.com"},
expectedSubject: "Your new link to 'secrets of the board'",
expectedTextBody: `Hello,
A new link to "secrets of the board", shared with you by Dr. S. Harer, has been requested.
Your PIN: 123456
Open the link below or enter the PIN on the page where you requested the new link. The link and the PIN are personal and can only be used once. Please do not forward them.
The link and the PIN are only valid for 30 minutes.
Click here to view it: https://cloud.example.com/g/v1.sharehash.newsecret
---
OpenCloud - a safe home for all your data
https://opencloud.eu
`,
expectedSender: sharer.GetDisplayName(),
expectedLink: testOpenCloudURL + "/g/v1.sharehash.newsecret",
done: make(chan struct{}),
}, events.Event{
Event: ocEvents.GuestTokenRenewed{
Sharer: sharer.GetId(),
GranteeEmail: "guest@example.com",
ItemID: resourceid,
Token: "v1.sharehash.newsecret",
Pin: "123456",
},
}),
)
})