Merge pull request #4564 from dragonchaser/email-template

Add email templating
This commit is contained in:
David Christofas
2022-09-22 14:02:36 +02:00
committed by GitHub
9 changed files with 377 additions and 29 deletions
+1
View File
@@ -29,6 +29,7 @@ func RegisteredEvents() []events.Unmarshaller {
events.SpaceEnabled{},
events.SpaceDisabled{},
events.SpaceDeleted{},
events.SpaceShared{},
events.UserCreated{},
events.UserDeleted{},
events.UserFeatureChanged{},
@@ -4,6 +4,7 @@ package channels
import (
"context"
"crypto/tls"
"fmt"
"strings"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
@@ -19,9 +20,9 @@ import (
// Channel defines the methods of a communication channel.
type Channel interface {
// SendMessage sends a message to users.
SendMessage(userIDs []string, msg string) error
SendMessage(userIDs []string, msg, subject, senderDisplayName string) error
// SendMessageToGroup sends a message to a group.
SendMessageToGroup(groupdID *groups.GroupId, msg string) error
SendMessageToGroup(groupdID *groups.GroupId, msg, subject, senderDisplayName string) error
}
// NewMailChannel instantiates a new mail communication channel.
@@ -100,7 +101,7 @@ func (m Mail) getMailClient() (*mail.SMTPClient, error) {
}
// SendMessage sends a message to all given users.
func (m Mail) SendMessage(userIDs []string, msg string) error {
func (m Mail) SendMessage(userIDs []string, msg, subject, senderDisplayName string) error {
if m.conf.Notifications.SMTP.Host == "" {
return nil
}
@@ -116,14 +117,19 @@ func (m Mail) SendMessage(userIDs []string, msg string) error {
}
email := mail.NewMSG()
email.SetFrom(m.conf.Notifications.SMTP.Sender).AddTo(to...)
if senderDisplayName != "" {
email.SetFrom(fmt.Sprintf("%s via owncloud <%s>", senderDisplayName, m.conf.Notifications.SMTP.Sender)).AddTo(to...)
} else {
email.SetFrom(m.conf.Notifications.SMTP.Sender).AddTo(to...)
}
email.SetBody(mail.TextPlain, msg)
email.SetSubject(subject)
return email.Send(smtpClient)
}
// SendMessageToGroup sends a message to all members of the given group.
func (m Mail) SendMessageToGroup(groupID *groups.GroupId, msg string) error {
func (m Mail) SendMessageToGroup(groupID *groups.GroupId, msg, subject, senderDisplayName string) error {
// TODO We need an authenticated context here...
res, err := m.gatewayClient.GetGroup(context.Background(), &groups.GetGroupRequest{GroupId: groupID})
if err != nil {
@@ -138,7 +144,7 @@ func (m Mail) SendMessageToGroup(groupID *groups.GroupId, msg string) error {
members = append(members, id.OpaqueId)
}
return m.SendMessage(members, msg)
return m.SendMessage(members, msg, subject, senderDisplayName)
}
func (m Mail) getReceiverAddresses(receivers []string) ([]string, error) {
+9 -1
View File
@@ -5,6 +5,7 @@ import (
"github.com/cs3org/reva/v2/pkg/events"
"github.com/cs3org/reva/v2/pkg/events/server"
"github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool"
"github.com/go-micro/plugins/v4/events/natsjs"
"github.com/owncloud/ocis/v2/ocis-pkg/config/configlog"
"github.com/owncloud/ocis/v2/services/notifications/pkg/channels"
@@ -27,8 +28,10 @@ func Server(cfg *config.Config) *cli.Command {
Action: func(c *cli.Context) error {
logger := logging.Configure(cfg.Service.Name, cfg.Log)
// evs defines a list of events to subscribe to
evs := []events.Unmarshaller{
events.ShareCreated{},
events.SpaceShared{},
}
evtsCfg := cfg.Notifications.Events
@@ -47,7 +50,12 @@ func Server(cfg *config.Config) *cli.Command {
if err != nil {
return err
}
svc := service.NewEventsNotifier(evts, channel, logger)
gwclient, err := pool.GetGatewayServiceClient(cfg.Notifications.RevaGateway)
if err != nil {
logger.Fatal().Err(err).Str("addr", cfg.Notifications.RevaGateway).Msg("could not get reva client")
}
svc := service.NewEventsNotifier(evts, channel, logger, gwclient, cfg.Commons.MachineAuthAPIKey, cfg.Notifications.EmailTemplatePath)
return svc.Run()
},
}
@@ -26,6 +26,7 @@ type Notifications struct {
Events Events `yaml:"events"`
RevaGateway string `yaml:"reva_gateway" env:"REVA_GATEWAY;NOTIFICATIONS_REVA_GATEWAY" desc:"CS3 gateway used to look up user metadata"`
MachineAuthAPIKey string `yaml:"machine_auth_api_key" env:"OCIS_MACHINE_AUTH_API_KEY;NOTIFICATIONS_MACHINE_AUTH_API_KEY" desc:"Machine auth API key used to validate internal requests necessary to access resources from other services."`
EmailTemplatePath string `yaml:"email_template_path" env:"OCIS_EMAIL_TEMPLATE_PATH;NOTIFICATIONS_EMAIL_TEMPLATE_PATH" desc:"Path to the E-Mail templates for the notifications to override the embedded ones."`
}
// SMTP combines the smtp configuration options.
+34
View File
@@ -0,0 +1,34 @@
package email
import (
"bytes"
"embed"
"html/template"
"path/filepath"
)
var (
//go:embed templates
templatesFS embed.FS
)
// RenderEmailTemplate renders the email template for a new share
func RenderEmailTemplate(templateName string, templateVariables map[string]string, emailTemplatePath string) (string, error) {
var err error
var tpl *template.Template
// try to lookup the files in the filesystem
tpl, err = template.ParseFiles(filepath.Join(emailTemplatePath, templateName))
if err != nil {
// template has not been found in the fs, or path has not been specified => use embed templates
tpl, err = template.ParseFS(templatesFS, filepath.Join("templates/", templateName))
if err != nil {
return "", err
}
}
var writer bytes.Buffer
err = tpl.Execute(&writer, templateVariables)
if err != nil {
return "", err
}
return writer.String(), nil
}
@@ -0,0 +1,18 @@
Hello {{ .ShareGrantee }},
{{ .ShareSharer }} has shared {{ .ShareFolder }} with you.
Click here to view it: {{ .ShareLink }}
----------------------------------------------------------
Hallo {{ .Grantee }},
{{ .ShareSharer }} hat dich zu {{ .ShareFolder }} eingeladen.
Klicke hier zum Anzeigen: {{ .ShareLink }}
---
ownCloud - Store. Share. Work.
https://owncloud.com
@@ -0,0 +1,18 @@
Hello {{ .SpaceGrantee }},
{{ .SpaceSharer }} has invited you to join {{ .SpaceName }}.
Click here to view it: {{ .ShareLink }}
----------------------------------------------------------
Hallo {{ .SpaceGrantee }},
{{ .SpaceSharer }} hat dich in den Space {{ .SpaceName }} eingeladen.
Klicke hier zum Anzeigen: {{ .ShareLink }}
---
ownCloud - Store. Share. Work.
https://owncloud.com
+277 -22
View File
@@ -1,33 +1,53 @@
package service
import (
"context"
"fmt"
"net/url"
"os"
"os/signal"
"path"
"syscall"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
ctxpkg "github.com/cs3org/reva/v2/pkg/ctx"
"github.com/cs3org/reva/v2/pkg/events"
"github.com/cs3org/reva/v2/pkg/storagespace"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/services/notifications/pkg/channels"
"github.com/owncloud/ocis/v2/services/notifications/pkg/email"
"google.golang.org/grpc/metadata"
"google.golang.org/protobuf/types/known/fieldmaskpb"
)
type Service interface {
Run() error
}
func NewEventsNotifier(events <-chan interface{}, channel channels.Channel, logger log.Logger) Service {
// NewEventsNotifier provides a new eventsNotifier
func NewEventsNotifier(events <-chan interface{}, channel channels.Channel, logger log.Logger, gwClient gateway.GatewayAPIClient, machineAuthAPIKey, emailTemplatePath string) Service {
return eventsNotifier{
logger: logger,
channel: channel,
events: events,
signals: make(chan os.Signal, 1),
logger: logger,
channel: channel,
events: events,
signals: make(chan os.Signal, 1),
gwClient: gwClient,
machineAuthAPIKey: machineAuthAPIKey,
emailTemplatePath: emailTemplatePath,
}
}
type eventsNotifier struct {
logger log.Logger
channel channels.Channel
events <-chan interface{}
signals chan os.Signal
logger log.Logger
channel channels.Channel
events <-chan interface{}
signals chan os.Signal
gwClient gateway.GatewayAPIClient
machineAuthAPIKey string
emailTemplatePath string
}
func (s eventsNotifier) Run() error {
@@ -39,20 +59,10 @@ func (s eventsNotifier) Run() error {
case evt := <-s.events:
go func() {
switch e := evt.(type) {
case events.SpaceShared:
s.handleSpaceShared(e)
case events.ShareCreated:
msg := "You got a share!"
var err error
if e.GranteeUserID != nil {
err = s.channel.SendMessage([]string{e.GranteeUserID.OpaqueId}, msg)
} else if e.GranteeGroupID != nil {
err = s.channel.SendMessageToGroup(e.GranteeGroupID, msg)
}
if err != nil {
s.logger.Error().
Err(err).
Str("event", "ShareCreated").
Msg("failed to send a message")
}
s.handleShareCreated(e)
}
}()
case <-s.signals:
@@ -62,3 +72,248 @@ func (s eventsNotifier) Run() error {
}
}
}
func (s eventsNotifier) handleSpaceShared(e events.SpaceShared) {
sharerUserResponse, err := s.gwClient.GetUser(context.Background(), &userv1beta1.GetUserRequest{
UserId: e.Creator,
})
if err != nil || sharerUserResponse.Status.Code != rpcv1beta1.Code_CODE_OK {
s.logger.Error().
Err(err).
Str("event", "SpaceCreated").
Msg("Could not get user response from gatway client")
return
}
granteeUserResponse, err := s.gwClient.GetUser(context.Background(), &userv1beta1.GetUserRequest{
UserId: e.GranteeUserID,
})
if err != nil || sharerUserResponse.Status.Code != rpcv1beta1.Code_CODE_OK {
s.logger.Error().
Err(err).
Str("event", "SpaceCreated").
Msg("Could not get user response from gatway client")
return
}
// Get auth context
ownerCtx := ctxpkg.ContextSetUser(context.Background(), sharerUserResponse.User)
authRes, err := s.gwClient.Authenticate(ownerCtx, &gateway.AuthenticateRequest{
Type: "machine",
ClientId: "userid:" + e.Executant.OpaqueId,
ClientSecret: s.machineAuthAPIKey,
})
if err != nil {
s.logger.Error().
Err(err).
Str("event", "SpaceCreated").
Msg("Could not impersonate sharer")
return
}
if authRes.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK {
s.logger.Error().
Err(err).
Str("event", "SpaceCreated").
Msg("could not get authenticated context for user")
return
}
ownerCtx = metadata.AppendToOutgoingContext(ownerCtx, ctxpkg.TokenHeader, authRes.Token)
resourceID, err := storagespace.ParseID(e.ID.OpaqueId)
if err != nil {
s.logger.Error().
Err(err).
Str("event", "SpaceCreated").
Str("itemid", e.ID.OpaqueId).
Msg("could not parse resourceid from ItemID ")
return
}
// TODO: maybe cache this stat to reduce storage iops
md, err := s.gwClient.Stat(ownerCtx, &providerv1beta1.StatRequest{
Ref: &providerv1beta1.Reference{
ResourceId: &resourceID,
},
// TODO: this filter needs to be implemented
//FieldMask: &fieldmaskpb.FieldMask{Paths: []string{"space.name"}},
})
if err != nil {
s.logger.Error().
Err(err).
Str("event", "ShareCreated").
Str("itemid", e.ID.OpaqueId).
Msg("could not stat resource")
return
}
if md.Status.Code != rpcv1beta1.Code_CODE_OK {
s.logger.Error().
Err(err).
Str("event", "ShareCreated").
Str("itemid", e.ID.OpaqueId).
Str("rpc status", md.Status.Code.String()).
Msg("could not stat resource")
return
}
shareLink, err := urlJoinPath(e.Executant.Idp, "files/spaces/projects", storagespace.FormatResourceID(*e.ID))
if err != nil {
s.logger.Error().
Err(err).
Str("event", "ShareCreated").
Msg("could not create link to the share")
return
}
sharerDisplayName := sharerUserResponse.GetUser().DisplayName
msg, err := email.RenderEmailTemplate("sharedSpace.email.tmpl", map[string]string{
"SpaceGrantee": granteeUserResponse.GetUser().DisplayName,
"SpaceSharer": sharerDisplayName,
"SpaceName": md.GetInfo().GetSpace().Name,
"ShareLink": shareLink,
}, s.emailTemplatePath)
if err != nil {
s.logger.Error().
Err(err).
Str("event", "SpaceCreated").
Msg("Could not render E-Mail template for spaces")
}
emailSubject := fmt.Sprintf("%s invited you to join %s", sharerUserResponse.GetUser().DisplayName, md.GetInfo().GetSpace().Name)
if e.GranteeUserID != nil {
err = s.channel.SendMessage([]string{e.GranteeUserID.OpaqueId}, msg, emailSubject, sharerDisplayName)
} else if e.GranteeGroupID != nil {
err = s.channel.SendMessageToGroup(e.GranteeGroupID, msg, emailSubject, sharerDisplayName)
}
if err != nil {
s.logger.Error().
Err(err).
Str("event", "SpaceCreated").
Msg("failed to send a message")
}
}
func (s eventsNotifier) handleShareCreated(e events.ShareCreated) {
sharerUserResponse, err := s.gwClient.GetUser(context.Background(), &userv1beta1.GetUserRequest{
UserId: e.Sharer,
})
if err != nil || sharerUserResponse.Status.Code != rpcv1beta1.Code_CODE_OK {
s.logger.Error().
Err(err).
Str("event", "ShareCreated").
Msg("Could not get user response from gatway client")
return
}
granteeUserResponse, err := s.gwClient.GetUser(context.Background(), &userv1beta1.GetUserRequest{
UserId: e.GranteeUserID,
})
if err != nil || sharerUserResponse.Status.Code != rpcv1beta1.Code_CODE_OK {
s.logger.Error().
Err(err).
Str("event", "ShareCreated").
Msg("Could not get user response from gatway client")
return
}
// Get auth context
ownerCtx := ctxpkg.ContextSetUser(context.Background(), sharerUserResponse.User)
authRes, err := s.gwClient.Authenticate(ownerCtx, &gateway.AuthenticateRequest{
Type: "machine",
ClientId: "userid:" + e.Sharer.OpaqueId,
ClientSecret: s.machineAuthAPIKey,
})
if err != nil {
s.logger.Error().
Err(err).
Str("event", "ShareCreated").
Msg("Could not impersonate sharer")
return
}
if authRes.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK {
s.logger.Error().
Err(err).
Str("event", "ShareCreated").
Msg("could not get authenticated context for user")
return
}
ownerCtx = metadata.AppendToOutgoingContext(ownerCtx, ctxpkg.TokenHeader, authRes.Token)
// TODO: maybe cache this stat to reduce storage iops
md, err := s.gwClient.Stat(ownerCtx, &providerv1beta1.StatRequest{
Ref: &providerv1beta1.Reference{
ResourceId: e.ItemID,
},
FieldMask: &fieldmaskpb.FieldMask{Paths: []string{"name"}},
})
if err != nil {
s.logger.Error().
Err(err).
Str("event", "ShareCreated").
Str("itemid", e.ItemID.OpaqueId).
Msg("could not stat resource")
return
}
if md.Status.Code != rpcv1beta1.Code_CODE_OK {
s.logger.Error().
Err(err).
Str("event", "ShareCreated").
Str("itemid", e.ItemID.OpaqueId).
Str("rpc status", md.Status.Code.String()).
Msg("could not stat resource")
return
}
shareLink, err := urlJoinPath(e.Executant.Idp, "files/shares/with-me")
if err != nil {
s.logger.Error().
Err(err).
Str("event", "ShareCreated").
Msg("could not create link to the share")
return
}
sharerDisplayName := sharerUserResponse.GetUser().DisplayName
msg, err := email.RenderEmailTemplate("shareCreated.email.tmpl", map[string]string{
"ShareGrantee": granteeUserResponse.GetUser().DisplayName,
"ShareSharer": sharerDisplayName,
"ShareFolder": md.GetInfo().Name,
"ShareLink": shareLink,
}, s.emailTemplatePath)
if err != nil {
s.logger.Error().
Err(err).
Str("event", "ShareCreated").
Msg("Could not render E-Mail template for shares")
}
emailSubject := fmt.Sprintf("%s shared %s with you", sharerUserResponse.GetUser().DisplayName, md.GetInfo().Name)
if e.GranteeUserID != nil {
err = s.channel.SendMessage([]string{e.GranteeUserID.OpaqueId}, msg, emailSubject, sharerDisplayName)
} else if e.GranteeGroupID != nil {
err = s.channel.SendMessageToGroup(e.GranteeGroupID, msg, emailSubject, sharerDisplayName)
}
if err != nil {
s.logger.Error().
Err(err).
Str("event", "ShareCreated").
Msg("failed to send a message")
}
}
// TODO: this function is a backport for go1.19 url.JoinPath, upon go bump, replace this
func urlJoinPath(base string, elements ...string) (string, error) {
u, err := url.Parse(base)
if err != nil {
return "", err
}
u.Path = path.Join(append([]string{u.Path}, elements...)...)
return u.String(), nil
}