Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
@@ -0,0 +1,160 @@
// Package channels provides different communication channels to notify users.
package channels
import (
"context"
"crypto/rand"
"crypto/tls"
"fmt"
stdmail "net/mail"
"strings"
"time"
"github.com/pkg/errors"
mail "github.com/xhit/go-simple-mail/v2"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/notifications/pkg/config"
)
// Channel defines the methods of a communication channel.
type Channel interface {
// SendMessage sends a message to users.
SendMessage(ctx context.Context, message *Message) error
}
// Message represent the already rendered message including the user id opaqueID
type Message struct {
Sender string
Recipient []string
Subject string
TextBody string
HTMLBody string
AttachInline map[string][]byte
}
// NewMailChannel instantiates a new mail communication channel.
func NewMailChannel(cfg config.Config, logger log.Logger) (Channel, error) {
a, err := stdmail.ParseAddress(cfg.Notifications.SMTP.Sender)
if err != nil {
logger.Err(err).Msg("parsing error, the 'smtp_sender' must be a valid single RFC 5322 address.")
return nil, err
}
return Mail{
conf: cfg,
smtpAddress: *a,
logger: logger,
}, nil
}
// Mail is the communication channel for email.
type Mail struct {
conf config.Config
smtpAddress stdmail.Address
logger log.Logger
}
func (m Mail) getMailClient() (*mail.SMTPClient, error) {
server := mail.NewSMTPClient()
server.Host = m.conf.Notifications.SMTP.Host
server.Port = m.conf.Notifications.SMTP.Port
server.Username = m.conf.Notifications.SMTP.Username
if server.Username == "" {
// compatibility fallback
server.Username = m.conf.Notifications.SMTP.Sender
}
server.Password = m.conf.Notifications.SMTP.Password
if server.TLSConfig == nil {
server.TLSConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
}
}
server.TLSConfig.InsecureSkipVerify = m.conf.Notifications.SMTP.Insecure
switch strings.ToLower(m.conf.Notifications.SMTP.Authentication) {
case "login":
server.Authentication = mail.AuthLogin
case "plain":
server.Authentication = mail.AuthPlain
case "crammd5":
server.Authentication = mail.AuthCRAMMD5
case "none":
server.Authentication = mail.AuthNone
case "auto", "":
server.Authentication = mail.AuthAuto
default:
return nil, errors.New("unknown mail authentication method")
}
switch strings.ToLower(m.conf.Notifications.SMTP.Encryption) {
case "starttls":
server.Encryption = mail.EncryptionSTARTTLS
server.TLSConfig.ServerName = m.conf.Notifications.SMTP.Host
case "ssltls":
server.Encryption = mail.EncryptionSSLTLS
case "none":
server.Encryption = mail.EncryptionNone
default:
return nil, errors.New("unknown mail encryption method")
}
smtpClient, err := server.Connect()
if err != nil {
return nil, err
}
return smtpClient, nil
}
// SendMessage sends a message to all given users.
func (m Mail) SendMessage(_ context.Context, message *Message) error {
if m.conf.Notifications.SMTP.Host == "" {
m.logger.Info().Str("mail", "SendMessage").Msg("failed to send a message. SMTP host is not set")
return nil
}
smtpClient, err := m.getMailClient()
if err != nil {
return err
}
email := mail.NewMSG()
email.SetFrom(appendSender(message.Sender, m.smtpAddress)).AddTo(message.Recipient...)
email.SetSubject(message.Subject)
email.AddHeader("Message-ID", generateMessageID(m.smtpAddress.Address))
email.SetBody(mail.TextPlain, message.TextBody)
if message.HTMLBody != "" {
email.AddAlternative(mail.TextHTML, message.HTMLBody)
for filename, data := range message.AttachInline {
email.Attach(&mail.File{Data: data, Name: filename, Inline: true})
}
}
return email.Send(smtpClient)
}
func appendSender(sender string, a stdmail.Address) string {
if strings.TrimSpace(sender) != "" {
a.Name = strings.TrimSpace(sender + " via " + a.Name)
}
return a.String()
}
// generateMessageID generates a unique Message-ID header value according to RFC 5322
func generateMessageID(domain string) string {
// Extract domain from email address if it contains @
if idx := strings.LastIndex(domain, "@"); idx != -1 {
domain = domain[idx+1:]
}
// Generate random bytes for uniqueness
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
// Fallback to timestamp-based ID if random fails
return fmt.Sprintf("<%d@%s>", time.Now().UnixNano(), domain)
}
// Create Message-ID: <timestamp.random@domain>
timestamp := time.Now().Unix()
return fmt.Sprintf("<%d.%x@%s>", timestamp, b, domain)
}
@@ -0,0 +1,64 @@
package channels
import (
"net/mail"
"testing"
)
func Test_appendSender(t *testing.T) {
type args struct {
sender string
a mail.Address
}
a1, err := mail.ParseAddress("КуСфера <noreply@example.com>")
if err != nil {
t.Error(err)
}
a2, err := mail.ParseAddress("noreply@example.com")
if err != nil {
t.Error(err)
}
tests := []struct {
name string
sender string
want1 string
want2 string
}{
{
name: "empty sender",
sender: "",
want1: `"КуСфера" <noreply@example.com>`,
want2: `<noreply@example.com>`,
},
{
name: "not empty sender",
sender: `Joe Q. Public`,
want1: `"Joe Q. Public via КуСфера" <noreply@example.com>`,
want2: `"Joe Q. Public via" <noreply@example.com>`,
},
{
name: "sender whit comma and semicolon",
sender: `Joe, Q; Public:`,
want1: `"Joe, Q; Public: via КуСфера" <noreply@example.com>`,
want2: `"Joe, Q; Public: via" <noreply@example.com>`,
},
{
name: "sender with quotes",
sender: `Joe Q. "Public"`,
want1: `"Joe Q. \"Public\" via КуСфера" <noreply@example.com>`,
want2: `"Joe Q. \"Public\" via" <noreply@example.com>`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := appendSender(tt.sender, *a1); got != tt.want1 {
t.Errorf("appendSender() = %v, want %v", got, tt.want1)
}
if got := appendSender(tt.sender, *a2); got != tt.want2 {
t.Errorf("appendSender() = %v, want %v", got, tt.want2)
}
})
}
}
@@ -0,0 +1,19 @@
package command
import (
"github.com/qsfera/server/services/notifications/pkg/config"
"github.com/spf13/cobra"
)
// Health is the entrypoint for the health command.
func Health(cfg *config.Config) *cobra.Command {
return &cobra.Command{
Use: "health",
Short: "Check health status",
RunE: func(cmd *cobra.Command, args []string) error {
// Not implemented
return nil
},
}
}
@@ -0,0 +1,37 @@
package command
import (
"os"
"github.com/qsfera/server/pkg/clihelper"
"github.com/qsfera/server/services/notifications/pkg/config"
"github.com/spf13/cobra"
)
// GetCommands provides all commands for this service
func GetCommands(cfg *config.Config) []*cobra.Command {
return []*cobra.Command{
// start this service
Server(cfg),
// interaction with this service
SendEmail(cfg),
// infos about this service
Health(cfg),
Version(cfg),
}
}
// Execute is the entry point for the notifications command.
func Execute(cfg *config.Config) error {
app := clihelper.DefaultApp(&cobra.Command{
Use: "notifications",
Short: "starts notifications service",
})
app.AddCommand(GetCommands(cfg)...)
app.SetArgs(os.Args[1:])
return app.ExecuteContext(cfg.Context)
}
@@ -0,0 +1,63 @@
package command
import (
"github.com/qsfera/server/pkg/generators"
"github.com/qsfera/server/services/notifications/pkg/config"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/events/stream"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
// SendEmail triggers the sending of grouped email notifications for daily or weekly emails.
func SendEmail(cfg *config.Config) *cobra.Command {
sendEmailCmd := &cobra.Command{
Use: "send-email",
Short: "Send grouped email notifications with daily or weekly interval. Specify at least one of the flags '--daily' or '--weekly'.",
RunE: func(cmd *cobra.Command, args []string) error {
daily, _ := cmd.Flags().GetBool("daily")
weekly, _ := cmd.Flags().GetBool("weekly")
if !daily && !weekly {
return errors.New("at least one of '--daily' or '--weekly' must be set")
}
connName := generators.GenerateConnectionName(cfg.Service.Name, generators.NTypeBus)
s, err := stream.NatsFromConfig(connName, false, stream.NatsConfig(cfg.Notifications.Events))
if err != nil {
return err
}
if daily {
err = events.Publish(cmd.Context(), s, events.SendEmailsEvent{
Interval: "daily",
})
if err != nil {
return err
}
}
if weekly {
err = events.Publish(cmd.Context(), s, events.SendEmailsEvent{
Interval: "weekly",
})
if err != nil {
return err
}
}
return nil
},
}
sendEmailCmd.Flags().BoolP(
"daily",
"d",
false,
"Sends grouped daily email notifications.",
)
sendEmailCmd.Flags().BoolP(
"weekly",
"w",
false,
"Sends grouped weekly email notifications.",
)
return sendEmailCmd
}
@@ -0,0 +1,157 @@
package command
import (
"context"
"fmt"
"os/signal"
"reflect"
"github.com/qsfera/server/pkg/config/configlog"
"github.com/qsfera/server/pkg/generators"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/registry"
"github.com/qsfera/server/pkg/runner"
"github.com/qsfera/server/pkg/service/grpc"
"github.com/qsfera/server/pkg/tracing"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/notifications/pkg/channels"
"github.com/qsfera/server/services/notifications/pkg/config"
"github.com/qsfera/server/services/notifications/pkg/config/parser"
"github.com/qsfera/server/services/notifications/pkg/server/debug"
"github.com/qsfera/server/services/notifications/pkg/service"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/events/stream"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
ehsvc "github.com/qsfera/server/protogen/gen/qsfera/services/eventhistory/v0"
"github.com/opencloud-eu/reva/v2/pkg/store"
"github.com/spf13/cobra"
microstore "go-micro.dev/v4/store"
)
// Server is the entrypoint for the server command.
func Server(cfg *config.Config) *cobra.Command {
return &cobra.Command{
Use: "server",
Short: fmt.Sprintf("start the %s service without runtime (unsupervised mode)", cfg.Service.Name),
PreRunE: func(cmd *cobra.Command, args []string) error {
return configlog.ReturnFatal(parser.ParseConfig(cfg))
},
RunE: func(cmd *cobra.Command, args []string) error {
logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel)
traceProvider, err := tracing.GetTraceProvider(cmd.Context(), cfg.Commons.TracesExporter, cfg.Service.Name)
if err != nil {
return err
}
grpcClient, err := grpc.NewClient(
append(
grpc.GetClientOptions(&cfg.GRPCClientTLS),
grpc.WithTraceProvider(traceProvider),
)...,
)
if err != nil {
return err
}
var cancel context.CancelFunc
if cfg.Context == nil {
cfg.Context, cancel = signal.NotifyContext(context.Background(), runner.StopSignals...)
defer cancel()
}
ctx := cfg.Context
gr := runner.NewGroup()
{
debugServer, err := debug.Server(
debug.Logger(logger),
debug.Context(ctx),
debug.Config(cfg),
)
if err != nil {
logger.Info().Err(err).Str("transport", "debug").Msg("Failed to initialize server")
return err
}
gr.Add(runner.NewGolangHttpServerRunner(cfg.Service.Name+".debug", debugServer))
}
// evs defines a list of events to subscribe to
evs := []events.Unmarshaller{
events.ShareCreated{},
events.ShareExpired{},
events.SpaceShared{},
events.SpaceUnshared{},
events.SpaceMembershipExpired{},
events.ScienceMeshInviteTokenGenerated{},
events.SendEmailsEvent{},
}
registeredEvents := make(map[string]events.Unmarshaller)
for _, e := range evs {
typ := reflect.TypeOf(e)
registeredEvents[typ.String()] = e
}
connName := generators.GenerateConnectionName(cfg.Service.Name, generators.NTypeBus)
client, err := stream.NatsFromConfig(connName, false, stream.NatsConfig(cfg.Notifications.Events))
if err != nil {
return err
}
evts, err := events.Consume(client, "notifications", evs...)
if err != nil {
return err
}
channel, err := channels.NewMailChannel(*cfg, logger)
if err != nil {
return err
}
tm, err := pool.StringToTLSMode(cfg.Notifications.GRPCClientTLS.Mode)
if err != nil {
return err
}
gatewaySelector, err := pool.GatewaySelector(
cfg.Notifications.RevaGateway,
pool.WithTLSCACert(cfg.Notifications.GRPCClientTLS.CACert),
pool.WithTLSMode(tm),
pool.WithRegistry(registry.GetRegistry()),
pool.WithTracerProvider(traceProvider),
)
if err != nil {
logger.Fatal().Err(err).Str("addr", cfg.Notifications.RevaGateway).Msg("could not get reva gateway selector")
}
valueService := settingssvc.NewValueService("qsfera.api.settings", grpcClient)
historyClient := ehsvc.NewEventHistoryService("qsfera.api.eventhistory", grpcClient)
notificationStore := store.Create(
store.Store(cfg.Store.Store),
store.TTL(cfg.Store.TTL),
microstore.Nodes(cfg.Store.Nodes...),
microstore.Database(cfg.Store.Database),
microstore.Table(cfg.Store.Table),
store.Authentication(cfg.Store.AuthUsername, cfg.Store.AuthPassword),
)
svc := service.NewEventsNotifier(evts, channel, logger, gatewaySelector, valueService,
cfg.ServiceAccount.ServiceAccountID, cfg.ServiceAccount.ServiceAccountSecret,
cfg.Notifications.EmailTemplatePath, cfg.Notifications.DefaultLanguage, cfg.WebUIURL,
cfg.Notifications.TranslationPath, cfg.Notifications.SMTP.Sender, notificationStore, historyClient, registeredEvents)
gr.Add(runner.New(cfg.Service.Name+".svc", func() error {
return svc.Run()
}, func() {
svc.Close()
}))
grResults := gr.Run(ctx)
// return the first non-nil error found in the results
for _, grResult := range grResults {
if grResult.RunnerError != nil {
return grResult.RunnerError
}
}
return nil
},
}
}
@@ -0,0 +1,19 @@
package command
import (
"github.com/qsfera/server/services/notifications/pkg/config"
"github.com/spf13/cobra"
)
// Version prints the service versions of all running instances.
func Version(cfg *config.Config) *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "print the version of this binary and the running service instances",
RunE: func(cmd *cobra.Command, args []string) error {
// not implemented
return nil
},
}
}
@@ -0,0 +1,80 @@
// Package config provides the service configuration.
package config
import (
"context"
"time"
"github.com/qsfera/server/pkg/shared"
)
// Config combines all available configuration parts.
type Config struct {
Commons *shared.Commons `yaml:"-"` // don't use this directly as configuration for a service
Service Service `yaml:"-"`
LogLevel string `yaml:"loglevel" env:"OC_LOG_LEVEL;NOTIFICATIONS_LOG_LEVEL" desc:"The log level. Valid values are: 'panic', 'fatal', 'error', 'warn', 'info', 'debug', 'trace'." introductionVersion:"1.0.0"`
Debug Debug `yaml:"debug"`
WebUIURL string `yaml:"qsfera_url" env:"OC_URL;NOTIFICATIONS_WEB_UI_URL" desc:"The public facing URL of the КуСфера Web UI, used e.g. when sending notification eMails" introductionVersion:"1.0.0"`
Notifications Notifications `yaml:"notifications"`
GRPCClientTLS shared.GRPCClientTLS `yaml:"grpc_client_tls"`
ServiceAccount ServiceAccount `yaml:"service_account"`
Context context.Context `yaml:"-"`
Store Store `yaml:"store"`
}
// Notifications defines the config options for the notifications service.
type Notifications struct {
SMTP SMTP `yaml:"SMTP"`
Events Events `yaml:"events"`
EmailTemplatePath string `yaml:"email_template_path" env:"OC_EMAIL_TEMPLATE_PATH;NOTIFICATIONS_EMAIL_TEMPLATE_PATH" desc:"Path to Email notification templates overriding embedded ones." introductionVersion:"1.0.0"`
TranslationPath string `yaml:"translation_path" env:"OC_TRANSLATION_PATH;NOTIFICATIONS_TRANSLATION_PATH" desc:"(optional) Set this to a path with custom translations to overwrite the builtin translations. Note that file and folder naming rules apply, see the documentation for more details." introductionVersion:"1.0.0"`
DefaultLanguage string `yaml:"default_language" env:"OC_DEFAULT_LANGUAGE" desc:"The default language used by services and the WebUI. If not defined, English will be used as default. See the documentation for more details." introductionVersion:"1.0.0"`
RevaGateway string `yaml:"reva_gateway" env:"OC_REVA_GATEWAY" desc:"CS3 gateway used to look up user metadata" introductionVersion:"1.0.0"`
GRPCClientTLS *shared.GRPCClientTLS `yaml:"grpc_client_tls"`
}
// SMTP combines the smtp configuration options.
type SMTP struct {
Host string `yaml:"smtp_host" env:"NOTIFICATIONS_SMTP_HOST" desc:"SMTP host to connect to." introductionVersion:"1.0.0"`
Port int `yaml:"smtp_port" env:"NOTIFICATIONS_SMTP_PORT" desc:"Port of the SMTP host to connect to." introductionVersion:"1.0.0"`
Sender string `yaml:"smtp_sender" env:"NOTIFICATIONS_SMTP_SENDER" desc:"Sender address of emails that will be sent (e.g. 'КуСфера <noreply@example.com>'." introductionVersion:"1.0.0"`
Username string `yaml:"smtp_username" env:"NOTIFICATIONS_SMTP_USERNAME" desc:"Username for the SMTP host to connect to." introductionVersion:"1.0.0"`
Password string `yaml:"smtp_password" env:"NOTIFICATIONS_SMTP_PASSWORD" desc:"Password for the SMTP host to connect to." introductionVersion:"1.0.0"`
Insecure bool `yaml:"insecure" env:"NOTIFICATIONS_SMTP_INSECURE" desc:"Allow insecure connections to the SMTP server." introductionVersion:"1.0.0"`
Authentication string `yaml:"smtp_authentication" env:"NOTIFICATIONS_SMTP_AUTHENTICATION" desc:"Authentication method for the SMTP communication. Possible values are 'login', 'plain', 'crammd5', 'none' or 'auto'. If set to 'auto' or unset, the authentication method is automatically negotiated with the server." introductionVersion:"1.0.0"`
Encryption string `yaml:"smtp_encryption" env:"NOTIFICATIONS_SMTP_ENCRYPTION" desc:"Encryption method for the SMTP communication. Possible values are 'starttls', 'ssltls' and 'none'." introductionVersion:"1.0.0"`
}
// Events combines the configuration options for the event bus.
type Events struct {
Endpoint string `yaml:"endpoint" env:"OC_EVENTS_ENDPOINT;NOTIFICATIONS_EVENTS_ENDPOINT" desc:"The address of the event system. The event system is the message queuing service. It is used as message broker for the microservice architecture." introductionVersion:"1.0.0"`
Cluster string `yaml:"cluster" env:"OC_EVENTS_CLUSTER;NOTIFICATIONS_EVENTS_CLUSTER" desc:"The clusterID of the event system. The event system is the message queuing service. It is used as message broker for the microservice architecture. Mandatory when using NATS as event system." introductionVersion:"1.0.0"`
TLSInsecure bool `yaml:"tls_insecure" env:"OC_INSECURE;OC_EVENTS_TLS_INSECURE;NOTIFICATIONS_EVENTS_TLS_INSECURE" desc:"Whether to verify the server TLS certificates." introductionVersion:"1.0.0"`
TLSRootCACertificate string `yaml:"tls_root_ca_certificate" env:"OC_EVENTS_TLS_ROOT_CA_CERTIFICATE;NOTIFICATIONS_EVENTS_TLS_ROOT_CA_CERTIFICATE" desc:"The root CA certificate used to validate the server's TLS certificate. If provided NOTIFICATIONS_EVENTS_TLS_INSECURE will be seen as false." introductionVersion:"1.0.0"`
EnableTLS bool `yaml:"enable_tls" env:"OC_EVENTS_ENABLE_TLS;NOTIFICATIONS_EVENTS_ENABLE_TLS" desc:"Enable TLS for the connection to the events broker. The events broker is the КуСфера service which receives and delivers events between the services." introductionVersion:"1.0.0"`
AuthUsername string `yaml:"username" env:"OC_EVENTS_AUTH_USERNAME;NOTIFICATIONS_EVENTS_AUTH_USERNAME" desc:"The username to authenticate with the events broker. The events broker is the КуСфера service which receives and delivers events between the services." introductionVersion:"1.0.0"`
AuthPassword string `yaml:"password" env:"OC_EVENTS_AUTH_PASSWORD;NOTIFICATIONS_EVENTS_AUTH_PASSWORD" desc:"The password to authenticate with the events broker. The events broker is the КуСфера service which receives and delivers events between the services." introductionVersion:"1.0.0"`
}
// ServiceAccount is the configuration for the used service account
type ServiceAccount struct {
ServiceAccountID string `yaml:"service_account_id" env:"OC_SERVICE_ACCOUNT_ID;NOTIFICATIONS_SERVICE_ACCOUNT_ID" desc:"The ID of the service account the service should use. See the 'auth-service' service description for more details." introductionVersion:"1.0.0"`
ServiceAccountSecret string `yaml:"service_account_secret" env:"OC_SERVICE_ACCOUNT_SECRET;NOTIFICATIONS_SERVICE_ACCOUNT_SECRET" desc:"The service account secret." introductionVersion:"1.0.0"`
}
// Store configures the store to use
type Store struct {
Store string `yaml:"store" env:"OC_PERSISTENT_STORE;NOTIFICATIONS_STORE" desc:"The type of the store. Supported values are: 'memory', 'nats-js-kv', 'redis-sentinel', 'noop'. See the text description for details." introductionVersion:"1.0.0"`
Nodes []string `yaml:"nodes" env:"OC_PERSISTENT_STORE_NODES;NOTIFICATIONS_STORE_NODES" desc:"A list of nodes to access the configured store. This has no effect when 'memory' store is configured. Note that the behaviour how nodes are used is dependent on the library of the configured store. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
Database string `yaml:"database" env:"NOTIFICATIONS_STORE_DATABASE" desc:"The database name the configured store should use." introductionVersion:"1.0.0"`
Table string `yaml:"table" env:"NOTIFICATIONS_STORE_TABLE" desc:"The database table the store should use." introductionVersion:"1.0.0"`
TTL time.Duration `yaml:"ttl" env:"OC_PERSISTENT_STORE_TTL;NOTIFICATIONS_STORE_TTL" desc:"Time to live for notifications in the store. Defaults to '336h' (2 weeks). See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
AuthUsername string `yaml:"username" env:"OC_PERSISTENT_STORE_AUTH_USERNAME;NOTIFICATIONS_STORE_AUTH_USERNAME" desc:"The username to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
AuthPassword string `yaml:"password" env:"OC_PERSISTENT_STORE_AUTH_PASSWORD;NOTIFICATIONS_STORE_AUTH_PASSWORD" desc:"The password to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
}
@@ -0,0 +1,9 @@
package config
// Debug defines the available debug configuration.
type Debug struct {
Addr string `yaml:"addr" env:"NOTIFICATIONS_DEBUG_ADDR" desc:"Bind address of the debug server, where metrics, health, config and debug endpoints will be exposed." introductionVersion:"1.0.0"`
Token string `yaml:"token" env:"NOTIFICATIONS_DEBUG_TOKEN" desc:"Token to secure the metrics endpoint." introductionVersion:"1.0.0"`
Pprof bool `yaml:"pprof" env:"NOTIFICATIONS_DEBUG_PPROF" desc:"Enables pprof, which can be used for profiling." introductionVersion:"1.0.0"`
Zpages bool `yaml:"zpages" env:"NOTIFICATIONS_DEBUG_ZPAGES" desc:"Enables zpages, which can be used for collecting and viewing in-memory traces." introductionVersion:"1.0.0"`
}
@@ -0,0 +1,69 @@
package defaults
import (
"time"
"github.com/qsfera/server/pkg/shared"
"github.com/qsfera/server/pkg/structs"
"github.com/qsfera/server/services/notifications/pkg/config"
)
// FullDefaultConfig returns a fully initialized default configuration
func FullDefaultConfig() *config.Config {
cfg := DefaultConfig()
EnsureDefaults(cfg)
Sanitize(cfg)
return cfg
}
// NOTE: Most of this configuration is not needed to keep it as simple as possible
// TODO: Clean up unneeded configuration
// DefaultConfig returns a basic default configuration
func DefaultConfig() *config.Config {
return &config.Config{
Debug: config.Debug{
Addr: "127.0.0.1:9174",
Zpages: false,
Pprof: false,
},
Service: config.Service{
Name: "notifications",
},
WebUIURL: "https://localhost:9200",
Notifications: config.Notifications{
SMTP: config.SMTP{
Encryption: "none",
},
Events: config.Events{
Endpoint: "127.0.0.1:9233",
Cluster: "qsfera-cluster",
EnableTLS: false,
},
RevaGateway: shared.DefaultRevaConfig().Address,
},
Store: config.Store{
Store: "nats-js-kv",
Nodes: []string{"127.0.0.1:9233"},
Database: "notifications",
Table: "",
TTL: 336 * time.Hour,
},
}
}
// EnsureDefaults adds default values to the configuration if they are not set yet
func EnsureDefaults(cfg *config.Config) {
if cfg.LogLevel == "" {
cfg.LogLevel = "error"
}
if cfg.Notifications.GRPCClientTLS == nil && cfg.Commons != nil {
cfg.Notifications.GRPCClientTLS = structs.CopyOrZeroValue(cfg.Commons.GRPCClientTLS)
}
}
// Sanitize sanitizes the configuration
func Sanitize(cfg *config.Config) {
// nothing to sanitize here atm
}
@@ -0,0 +1,77 @@
package parser
import (
"errors"
"fmt"
"net/mail"
"strings"
occfg "github.com/qsfera/server/pkg/config"
"github.com/qsfera/server/pkg/config/envdecode"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/shared"
"github.com/qsfera/server/services/notifications/pkg/config"
"github.com/qsfera/server/services/notifications/pkg/config/defaults"
)
// ParseConfig loads configuration from known paths.
func ParseConfig(cfg *config.Config) error {
err := occfg.BindSourcesToStructs(cfg.Service.Name, cfg)
if err != nil {
return err
}
defaults.EnsureDefaults(cfg)
// load all env variables relevant to the config in the current context.
if err := envdecode.Decode(cfg); err != nil {
// no environment variable set for this config is an expected "error"
if !errors.Is(err, envdecode.ErrNoTargetFieldsAreSet) {
return err
}
}
defaults.Sanitize(cfg)
return Validate(cfg)
}
func Validate(cfg *config.Config) error {
logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel)
if cfg.Notifications.SMTP.Host != "" {
switch cfg.Notifications.SMTP.Encryption {
case "tls":
logger.Warn().Msg("The smtp_encryption value 'tls' is deprecated. Please use the value 'starttls' instead.")
case "ssl":
logger.Warn().Msg("The smtp_encryption value 'ssl' is deprecated. Please use the value 'ssltls' instead.")
case "starttls", "ssltls", "none":
break
default:
return fmt.Errorf(
"unknown value '%s' for 'smtp_encryption' in service %s. Allowed values are 'starttls', 'ssltls' or 'none'",
cfg.Notifications.SMTP.Encryption, cfg.Service.Name,
)
}
}
{
if strings.TrimSpace(cfg.Notifications.SMTP.Sender) == "" {
return fmt.Errorf("the 'smtp_sender' must be a valid single RFC 5322 address. parsing error: the address cannot be empty")
}
if s, err := mail.ParseAddress(cfg.Notifications.SMTP.Sender); err == nil {
cfg.Notifications.SMTP.Sender = s.String()
} else {
return fmt.Errorf("the 'smtp_sender' must be a valid single RFC 5322 address. parsing error %w", err)
}
}
if cfg.ServiceAccount.ServiceAccountID == "" {
return shared.MissingServiceAccountID(cfg.Service.Name)
}
if cfg.ServiceAccount.ServiceAccountSecret == "" {
return shared.MissingServiceAccountSecret(cfg.Service.Name)
}
return nil
}
@@ -0,0 +1,6 @@
package config
// Service defines the available service configuration.
type Service struct {
Name string `yaml:"-"`
}
@@ -0,0 +1,155 @@
package email
import (
"bytes"
"embed"
"strings"
"text/template"
"github.com/pkg/errors"
"github.com/qsfera/server/pkg/l10n"
)
var (
//go:embed l10n/locale
_translationFS embed.FS
_domain = "notifications"
)
// NewTextTemplate replace the body message template placeholders with the translated template
func NewTextTemplate(mt MessageTemplate, locale, defaultLocale string, translationPath string, vars map[string]string) (MessageTemplate, error) {
var err error
t := l10n.NewTranslatorFromCommonConfig(defaultLocale, _domain, translationPath, _translationFS, "l10n/locale").Locale(locale)
mt.Subject, err = composeMessage(t.Get(mt.Subject, []any{}...), vars)
if err != nil {
return mt, err
}
mt.Greeting, err = composeMessage(t.Get(mt.Greeting, []any{}...), vars)
if err != nil {
return mt, err
}
mt.MessageBody, err = composeMessage(t.Get(mt.MessageBody, []any{}...), vars)
if err != nil {
return mt, err
}
mt.CallToAction, err = composeMessage(t.Get(mt.CallToAction, []any{}...), vars)
if err != nil {
return mt, err
}
return mt, nil
}
// NewHTMLTemplate replace the body message template placeholders with the translated template
func NewHTMLTemplate(mt MessageTemplate, locale, defaultLocale string, translationPath string, vars map[string]string) (MessageTemplate, error) {
var err error
t := l10n.NewTranslatorFromCommonConfig(defaultLocale, _domain, translationPath, _translationFS, "l10n/locale").Locale(locale)
mt.Subject, err = composeMessage(t.Get(mt.Subject, []any{}...), vars)
if err != nil {
return mt, err
}
mt.Greeting, err = composeMessage(newlineToBr(t.Get(mt.Greeting, []any{}...)), vars)
if err != nil {
return mt, err
}
mt.MessageBody, err = composeMessage(newlineToBr(t.Get(mt.MessageBody, []any{}...)), vars)
if err != nil {
return mt, err
}
mt.CallToAction, err = composeMessage(callToActionToHTML(t.Get(mt.CallToAction, []any{}...)), vars)
if err != nil {
return mt, err
}
return mt, nil
}
// NewGroupedTextTemplate replace the body message template placeholders with the translated template
func NewGroupedTextTemplate(gmt GroupedMessageTemplate, vars map[string]string, locale, defaultLocale string, translationPath string, mts []MessageTemplate, mtsVars []map[string]string) (GroupedMessageTemplate, error) {
if len(mts) != len(mtsVars) {
return gmt, errors.New("number of templates does not match number of variables")
}
var err error
t := l10n.NewTranslatorFromCommonConfig(defaultLocale, _domain, translationPath, _translationFS, "l10n/locale").Locale(locale)
gmt.Subject, err = composeMessage(t.Get(gmt.Subject, []any{}...), vars)
if err != nil {
return gmt, err
}
gmt.Greeting, err = composeMessage(t.Get(gmt.Greeting, []any{}...), vars)
if err != nil {
return gmt, err
}
bodyParts := make([]string, 0, len(mtsVars))
for i, mt := range mts {
bodyPart, err := composeMessage(t.Get(mt.MessageBody, []any{}...), mtsVars[i])
if err != nil {
return gmt, err
}
bodyParts = append(bodyParts, bodyPart)
}
gmt.MessageBody = strings.Join(bodyParts, "\n\n\n")
return gmt, nil
}
// NewGroupedHTMLTemplate replace the body message template placeholders with the translated template
func NewGroupedHTMLTemplate(gmt GroupedMessageTemplate, vars map[string]string, locale, defaultLocale string, translationPath string, mts []MessageTemplate, mtsVars []map[string]string) (GroupedMessageTemplate, error) {
if len(mts) != len(mtsVars) {
return gmt, errors.New("number of templates does not match number of variables")
}
var err error
t := l10n.NewTranslatorFromCommonConfig(defaultLocale, _domain, translationPath, _translationFS, "l10n/locale").Locale(locale)
gmt.Subject, err = composeMessage(t.Get(gmt.Subject, []any{}...), vars)
if err != nil {
return gmt, err
}
gmt.Greeting, err = composeMessage(newlineToBr(t.Get(gmt.Greeting, []any{}...)), vars)
if err != nil {
return gmt, err
}
bodyParts := make([]string, 0, len(mtsVars))
for i, mt := range mts {
bodyPart, err := composeMessage(t.Get(mt.MessageBody, []any{}...), mtsVars[i])
if err != nil {
return gmt, err
}
bodyParts = append(bodyParts, bodyPart)
}
gmt.MessageBody = strings.Join(bodyParts, "<br><br><br>")
return gmt, nil
}
// composeMessage renders the message based on template
func composeMessage(tmpl string, vars map[string]string) (string, error) {
tpl, err := template.New("").Parse(replacePlaceholders(tmpl))
if err != nil {
return "", err
}
var writer bytes.Buffer
if err := tpl.Execute(&writer, vars); err != nil {
return "", err
}
return writer.String(), nil
}
func replacePlaceholders(raw string) string {
for o, n := range _placeholders {
raw = strings.ReplaceAll(raw, o, n)
}
return raw
}
func newlineToBr(s string) string {
return strings.Replace(s, "\n", "<br>", -1)
}
func callToActionToHTML(s string) string {
if strings.TrimSpace(s) == "" {
return ""
}
s = strings.TrimSuffix(s, "{ShareLink}")
return s + `<a href="{ShareLink}">{ShareLink}</a>`
}
@@ -0,0 +1,211 @@
// Package email implements utility for rendering the Email.
//
// The email package supports transifex translation for email templates.
package email
import (
"bytes"
"embed"
"errors"
"html"
"html/template"
"io/fs"
"os"
"path/filepath"
"strings"
"syscall"
"github.com/qsfera/server/services/notifications/pkg/channels"
)
var (
//go:embed templates
templatesFS embed.FS
imgDir = filepath.Join("templates", "html", "img")
)
// RenderEmailTemplate is responsible to prepare a message which than can be used to notify the user via email.
func RenderEmailTemplate(mt MessageTemplate, locale, defaultLocale string, emailTemplatePath string, translationPath string, vars map[string]string) (*channels.Message, error) {
textMt, err := NewTextTemplate(mt, locale, defaultLocale, translationPath, vars)
if err != nil {
return nil, err
}
tpl, err := parseTemplate(emailTemplatePath, mt.textTemplate)
if err != nil {
return nil, err
}
textBody, err := emailTemplate(tpl, textMt)
if err != nil {
return nil, err
}
htmlMt, err := NewHTMLTemplate(mt, locale, defaultLocale, translationPath, escapeStringMap(vars))
if err != nil {
return nil, err
}
htmlTpl, err := parseTemplate(emailTemplatePath, mt.htmlTemplate)
if err != nil {
return nil, err
}
htmlBody, err := emailTemplate(htmlTpl, htmlMt)
if err != nil {
return nil, err
}
var data map[string][]byte
if emailTemplatePath != "" {
data, err = readImages(emailTemplatePath)
if err != nil {
return nil, err
}
}
return &channels.Message{
Subject: textMt.Subject,
TextBody: textBody,
HTMLBody: htmlBody,
AttachInline: data,
}, nil
}
// RenderGroupedEmailTemplate is responsible to prepare a message which than can be used to notify the user via email.
func RenderGroupedEmailTemplate(gmt GroupedMessageTemplate, vars map[string]string, locale, defaultLocale string, emailTemplatePath string, translationPath string, mts []MessageTemplate, mtsVars []map[string]string) (*channels.Message, error) {
textMt, err := NewGroupedTextTemplate(gmt, vars, locale, defaultLocale, translationPath, mts, mtsVars)
if err != nil {
return nil, err
}
tpl, err := parseTemplate(emailTemplatePath, gmt.textTemplate)
if err != nil {
return nil, err
}
textBody, err := groupedEmailTemplate(tpl, textMt)
if err != nil {
return nil, err
}
escapedMtsVars := make([]map[string]string, 0, len(mtsVars))
for _, m := range mtsVars {
escapedMtsVars = append(escapedMtsVars, escapeStringMap(m))
}
htmlMt, err := NewGroupedHTMLTemplate(gmt, escapeStringMap(vars), locale, defaultLocale, translationPath, mts, escapedMtsVars)
if err != nil {
return nil, err
}
htmlTpl, err := parseTemplate(emailTemplatePath, gmt.htmlTemplate)
if err != nil {
return nil, err
}
htmlBody, err := groupedEmailTemplate(htmlTpl, htmlMt)
if err != nil {
return nil, err
}
var data map[string][]byte
if emailTemplatePath != "" {
data, err = readImages(emailTemplatePath)
if err != nil {
return nil, err
}
}
return &channels.Message{
Subject: textMt.Subject,
TextBody: textBody,
HTMLBody: htmlBody,
AttachInline: data,
}, nil
}
// emailTemplate builds the email template. It does not use any user provided input, so it is safe to use template.HTML.
func emailTemplate(tpl *template.Template, mt MessageTemplate) (string, error) {
str, err := executeTemplate(tpl, map[string]any{
"Greeting": template.HTML(strings.TrimSpace(mt.Greeting)), // #nosec G203
"MessageBody": template.HTML(strings.TrimSpace(mt.MessageBody)), // #nosec G203
"CallToAction": template.HTML(strings.TrimSpace(mt.CallToAction)), // #nosec G203
})
if err != nil {
return "", err
}
return str, err
}
// groupedEmailTemplate builds the email template. It does not use any user provided input, so it is safe to use template.HTML.
func groupedEmailTemplate(tpl *template.Template, gmt GroupedMessageTemplate) (string, error) {
str, err := executeTemplate(tpl, map[string]any{
"Greeting": template.HTML(strings.TrimSpace(gmt.Greeting)), // #nosec G203
"MessageBody": template.HTML(strings.TrimSpace(gmt.MessageBody)), // #nosec G203
})
if err != nil {
return "", err
}
return str, err
}
func parseTemplate(emailTemplatePath string, file string) (*template.Template, error) {
if emailTemplatePath != "" {
return template.ParseFiles(filepath.Join(emailTemplatePath, file))
}
return template.ParseFS(templatesFS, filepath.Join(file))
}
func executeTemplate(tpl *template.Template, vars any) (string, error) {
var writer bytes.Buffer
if err := tpl.Execute(&writer, vars); err != nil {
return "", err
}
return writer.String(), nil
}
func readImages(emailTemplatePath string) (map[string][]byte, error) {
dir := filepath.Join(emailTemplatePath, imgDir)
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
return read(entries, os.DirFS(emailTemplatePath))
}
func read(entries []fs.DirEntry, fsys fs.FS) (map[string][]byte, error) {
list := make(map[string][]byte)
for _, e := range entries {
if e.IsDir() {
continue
}
file, err := fs.ReadFile(fsys, filepath.Join(imgDir, e.Name()))
if err != nil {
// skip if the symbolic is a directory
if errors.Is(err, syscall.EISDIR) {
continue
}
return nil, err
}
if !validateMime(file) {
continue
}
list[e.Name()] = file
}
return list, nil
}
// signature image formats signature https://go.dev/src/net/http/sniff.go #L:118
var signature = map[string]string{
"\xff\xd8\xff": "image/jpeg",
"\x89PNG\r\n\x1a\n": "image/png",
"GIF87a": "image/gif",
"GIF89a": "image/gif",
}
// validateMime validate the mime type of image file from its first few bytes
func validateMime(incipit []byte) bool {
for s := range signature {
if strings.HasPrefix(string(incipit), s) {
return true
}
}
return false
}
func escapeStringMap(vars map[string]string) map[string]string {
for k := range vars {
vars[k] = html.EscapeString(vars[k])
}
return vars
}
@@ -0,0 +1,10 @@
[main]
host = https://www.transifex.com
[o:qsfera-eu:p:qsfera-eu:r:qsfera-notifications]
file_filter = locale/<lang>/LC_MESSAGES/notifications.po
minimum_perc = 75
resource_name = qsfera-notifications
source_file = notifications.pot
source_lang = en
type = PO
@@ -0,0 +1,179 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Ivan Fustero, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-22 00:03+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Ivan Fustero, 2025\n"
"Language-Team: Catalan (https://app.transifex.com/qsfera-eu/teams/204053/ca/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: ca\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. UnsharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:65
msgid "Click here to check it: {ShareLink}"
msgstr "Clica aquí per verificar: {ShareLink}"
#. ShareCreated email template, resolves via {{ .CallToAction }}
#. SharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:23 pkg/email/templates.go:50
msgid "Click here to view it: {ShareLink}"
msgstr "Clica aquí per visualitzar: {ShareLink}"
#. ShareCreated email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:19
msgid "Hello {ShareGrantee}"
msgstr "Hola, {ShareGrantee}"
#. ShareExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:32
msgid "Hello {ShareGrantee},"
msgstr "Hola, {ShareGrantee},"
#. SharedSpace email template, resolves via {{ .Greeting }}
#. UnsharedSpace email template, resolves via {{ .Greeting }}
#. MembershipExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:46 pkg/email/templates.go:59
#: pkg/email/templates.go:74
msgid "Hello {SpaceGrantee},"
msgstr "Hola, {SpaceGrantee},"
#. Grouped email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:118
msgid "Hi {DisplayName},"
msgstr "Hola, {DisplayName},"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{ .Greeting
#. }}
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .Greeting }}
#: pkg/email/templates.go:87 pkg/email/templates.go:104
msgid "Hi,"
msgstr "Hola,"
#. MembershipExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:72
msgid "Membership of '{SpaceName}' expired at {ExpiredAt}"
msgstr "Incripció a '{SpaceName}' ha caducat el {ExpiredAt}"
#. Grouped email template, Subject field (resolves directly)
#: pkg/email/templates.go:116
msgid "Report"
msgstr "Informe"
#. ScienceMeshInviteTokenGenerated email template, Subject field (resolves
#. directly)
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, Subject
#. field (resolves directly)
#: pkg/email/templates.go:85 pkg/email/templates.go:102
msgid "ScienceMesh: {InitiatorName} wants to collaborate with you"
msgstr "ScienceMesh: {InitiatorName} vol col·laborar amb vós"
#. ShareExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:30
msgid "Share to '{ShareFolder}' expired at {ExpiredAt}"
msgstr "Compartir a '{ShareFolder}' caducat el {ExpiredAt}"
#. MembershipExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:76
msgid ""
"Your membership of space {SpaceName} has expired at {ExpiredAt}\n"
"\n"
"Even though this membership has expired you still might have access through other shares and/or space memberships"
msgstr ""
"La seva inscripció al espai {SpaceName} ha caducat el {ExpiredAt}\n"
"\n"
"Encara que aquesta inscripció hagi caducat, podria seguir tenint accés a través d'altres comparticions i/o espais inscrit."
#. ShareExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:34
msgid ""
"Your share to {ShareFolder} has expired at {ExpiredAt}\n"
"\n"
"Even though this share has been revoked you still might have access through other shares and/or space memberships."
msgstr ""
"La teva compartició a {ShareFolder} ha caducat el {ExpiredAt}\n"
"\n"
"Encara que aquesta compartició ha estat cancel·lada, podria seguir tenint accés a través d'altres comparticions i/o espais inscrits."
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .MessageBody }}
#: pkg/email/templates.go:106
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"Please visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
"{ShareSharer} ({ShareSharerMail}) vol començar a compartir recursos de col·laboració amb vós.\n"
"Visiteu la configuració de la federació i utilitzeu els detalls següents:\n"
"Testimoni (token): {Token}\n"
"Domini del proveïdor: {ProviderDomain}"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{
#. .MessageBody }}
#: pkg/email/templates.go:89
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"To accept the invite, please visit the following URL:\n"
"{ShareLink}\n"
"\n"
"Alternatively, you can visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
"{ShareSharer} ({ShareSharerMail}) vol començar a compartir recursos de col·laboració amb vós.\n"
"Per acceptar la invitació, visiteu la següent URL:\n"
"{ShareLink}\n"
"\n"
"Alternativament, podeu visitar la configuració de la vostra federació i utilitzar els següents detalls:\n"
"Testimoni (token): {Token}\n"
"Domini del proveïdor: {ProviderDomain}"
#. ShareCreated email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:21
msgid "{ShareSharer} has shared \"{ShareFolder}\" with you."
msgstr "{ShareSharer} ha compartit \"{ShareFolder}\" amb tu."
#. ShareCreated email template, Subject field (resolves directly)
#: pkg/email/templates.go:17
msgid "{ShareSharer} shared '{ShareFolder}' with you"
msgstr "{ShareSharer} comparteix \"{ShareFolder}\" amb tu"
#. SharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:48
msgid "{SpaceSharer} has invited you to join \"{SpaceName}\"."
msgstr "{SpaceSharer} l'ha invitat a unir-se a \"{SpaceName}\"."
#. UnsharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:61
msgid ""
"{SpaceSharer} has removed you from \"{SpaceName}\".\n"
"\n"
"You might still have access through your other groups or direct membership."
msgstr ""
"{SpaceSharer} l'ha eliminat de \"{SpaceName}\".\n"
"\n"
"Podria seguir tenint accés a través d'altres grups o d'inscripció directa."
#. SharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:44
msgid "{SpaceSharer} invited you to join {SpaceName}"
msgstr "{SpaceSharer} l'invita a unir-se {SpaceName}"
#. UnsharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:57
msgid "{SpaceSharer} removed you from {SpaceName}"
msgstr "{SpaceSharer} l'ha eliminat de {SpaceName}"
@@ -0,0 +1,180 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Jörn Friedrich Dreyer <jfd@butonic.de>, 2025
# Jonas, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-22 00:03+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Jonas, 2025\n"
"Language-Team: German (https://app.transifex.com/qsfera-eu/teams/204053/de/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: de\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. UnsharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:65
msgid "Click here to check it: {ShareLink}"
msgstr "Zum Überprüfen hier klicken: {ShareLink}"
#. ShareCreated email template, resolves via {{ .CallToAction }}
#. SharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:23 pkg/email/templates.go:50
msgid "Click here to view it: {ShareLink}"
msgstr "Zum Ansehen hier klicken: {ShareLink}"
#. ShareCreated email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:19
msgid "Hello {ShareGrantee}"
msgstr "Hallo {ShareGrantee}"
#. ShareExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:32
msgid "Hello {ShareGrantee},"
msgstr "Hallo {ShareGrantee},"
#. SharedSpace email template, resolves via {{ .Greeting }}
#. UnsharedSpace email template, resolves via {{ .Greeting }}
#. MembershipExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:46 pkg/email/templates.go:59
#: pkg/email/templates.go:74
msgid "Hello {SpaceGrantee},"
msgstr "Hallo {SpaceGrantee},"
#. Grouped email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:118
msgid "Hi {DisplayName},"
msgstr "Hallo {DisplayName},"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{ .Greeting
#. }}
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .Greeting }}
#: pkg/email/templates.go:87 pkg/email/templates.go:104
msgid "Hi,"
msgstr "Hallo,"
#. MembershipExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:72
msgid "Membership of '{SpaceName}' expired at {ExpiredAt}"
msgstr "Die Mitgliedschaft für '{SpaceName}' ist am {ExpiredAt} abgelaufen."
#. Grouped email template, Subject field (resolves directly)
#: pkg/email/templates.go:116
msgid "Report"
msgstr "Bericht"
#. ScienceMeshInviteTokenGenerated email template, Subject field (resolves
#. directly)
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, Subject
#. field (resolves directly)
#: pkg/email/templates.go:85 pkg/email/templates.go:102
msgid "ScienceMesh: {InitiatorName} wants to collaborate with you"
msgstr "ScienceMesh: {InitiatorName} möchte mir Ihnen gerne zusammenarbeiten"
#. ShareExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:30
msgid "Share to '{ShareFolder}' expired at {ExpiredAt}"
msgstr "Die Freigabe für '{ShareFolder}' ist am {ExpiredAt} abgelaufen."
#. MembershipExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:76
msgid ""
"Your membership of space {SpaceName} has expired at {ExpiredAt}\n"
"\n"
"Even though this membership has expired you still might have access through other shares and/or space memberships"
msgstr ""
"Ihre Mitgliedschaft im Space {SpaceName} ist am {ExpiredAt} abgelaufen.\n"
"Über eventuelle weitere Freigaben oder Space-Mitgliedschaften kann der Zugriff trotzdem weiterhin möglich sein."
#. ShareExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:34
msgid ""
"Your share to {ShareFolder} has expired at {ExpiredAt}\n"
"\n"
"Even though this share has been revoked you still might have access through other shares and/or space memberships."
msgstr ""
"Ihre Freigabe für {ShareFolder} ist am {ExpiredAt} abgelaufen.\n"
"\n"
"Über eventuelle weitere Freigaben oder Space-Mitgliedschaften kann der Zugriff trotzdem weiterhin möglich sein."
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .MessageBody }}
#: pkg/email/templates.go:106
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"Please visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
"{ShareSharer} ({ShareSharerMail}) möchte Bereiche für die Zusammenarbeit mit Ihnen teilen.\n"
"Gehen Sie zu Ihren Föderationseinstellungen und verwenden Sie die folgenden Angaben:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{
#. .MessageBody }}
#: pkg/email/templates.go:89
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"To accept the invite, please visit the following URL:\n"
"{ShareLink}\n"
"\n"
"Alternatively, you can visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
"{ShareSharer} ({ShareSharerMail}) möchte Bereiche für die Zusammenarbeit mit Ihnen teilen.\n"
"Um die Einladung anzunehmen, besuchen Sie bitte die folgende URL:\n"
"{ShareLink}\n"
"\n"
"Alternativ, können Sie auch in Ihre Föderationseinstellungen gehen und folgende Angaben verwenden:\n"
"{ShareLink}\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
#. ShareCreated email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:21
msgid "{ShareSharer} has shared \"{ShareFolder}\" with you."
msgstr "{ShareSharer} hat \"{ShareFolder}\" mit Ihnen geteilt."
#. ShareCreated email template, Subject field (resolves directly)
#: pkg/email/templates.go:17
msgid "{ShareSharer} shared '{ShareFolder}' with you"
msgstr "{ShareSharer} hat \"{ShareFolder}\" mit Ihnen geteilt"
#. SharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:48
msgid "{SpaceSharer} has invited you to join \"{SpaceName}\"."
msgstr "{SpaceSharer} hat Sie eingeladen, dem Space \"{SpaceName}\" beizutreten."
#. UnsharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:61
msgid ""
"{SpaceSharer} has removed you from \"{SpaceName}\".\n"
"\n"
"You might still have access through your other groups or direct membership."
msgstr ""
"{SpaceSharer} hat Sie aus dem Space \"{SpaceName}\" entfernt.\n"
"\n"
"Über eventuelle weitere Freigaben oder Space-Mitgliedschaften kann der Zugriff trotzdem weiterhin möglich sein."
#. SharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:44
msgid "{SpaceSharer} invited you to join {SpaceName}"
msgstr "{SpaceSharer} hat Sie eingeladen, dem Space {SpaceName} beizutreten."
#. UnsharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:57
msgid "{SpaceSharer} removed you from {SpaceName}"
msgstr "{SpaceSharer} hat Sie aus dem Space {SpaceName} entfernt."
@@ -0,0 +1,179 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Efstathios Iosifidis <eiosifidis@gmail.com>, 2026
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-23 00:03+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Efstathios Iosifidis <eiosifidis@gmail.com>, 2026\n"
"Language-Team: Greek (https://app.transifex.com/qsfera-eu/teams/204053/el/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: el\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. UnsharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:65
msgid "Click here to check it: {ShareLink}"
msgstr "Κάντε κλικ εδώ για έλεγχο: {ShareLink}"
#. ShareCreated email template, resolves via {{ .CallToAction }}
#. SharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:23 pkg/email/templates.go:50
msgid "Click here to view it: {ShareLink}"
msgstr "Κάντε κλικ εδώ για προβολή: {ShareLink}"
#. ShareCreated email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:19
msgid "Hello {ShareGrantee}"
msgstr "Γεια σας {ShareGrantee}"
#. ShareExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:32
msgid "Hello {ShareGrantee},"
msgstr "Γεια σας {ShareGrantee},"
#. SharedSpace email template, resolves via {{ .Greeting }}
#. UnsharedSpace email template, resolves via {{ .Greeting }}
#. MembershipExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:46 pkg/email/templates.go:59
#: pkg/email/templates.go:74
msgid "Hello {SpaceGrantee},"
msgstr "Γεια σας {SpaceGrantee},"
#. Grouped email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:118
msgid "Hi {DisplayName},"
msgstr "Γεια σου {DisplayName},"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{ .Greeting
#. }}
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .Greeting }}
#: pkg/email/templates.go:87 pkg/email/templates.go:104
msgid "Hi,"
msgstr "Γεια σας,"
#. MembershipExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:72
msgid "Membership of '{SpaceName}' expired at {ExpiredAt}"
msgstr "Η ιδιότητα μέλους στο '{SpaceName}' έληξε στις {ExpiredAt}"
#. Grouped email template, Subject field (resolves directly)
#: pkg/email/templates.go:116
msgid "Report"
msgstr "Αναφορά"
#. ScienceMeshInviteTokenGenerated email template, Subject field (resolves
#. directly)
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, Subject
#. field (resolves directly)
#: pkg/email/templates.go:85 pkg/email/templates.go:102
msgid "ScienceMesh: {InitiatorName} wants to collaborate with you"
msgstr "ScienceMesh: Ο/Η {InitiatorName} θέλει να συνεργαστεί μαζί σας"
#. ShareExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:30
msgid "Share to '{ShareFolder}' expired at {ExpiredAt}"
msgstr "Ο διαμοιρασμός του '{ShareFolder}' έληξε στις {ExpiredAt}"
#. MembershipExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:76
msgid ""
"Your membership of space {SpaceName} has expired at {ExpiredAt}\n"
"\n"
"Even though this membership has expired you still might have access through other shares and/or space memberships"
msgstr ""
"Η ιδιότητα μέλους σας στον χώρο {SpaceName} έληξε στις {ExpiredAt}\n"
"\n"
"Παρόλο που αυτή η ιδιότητα μέλους έχει λήξει, ενδέχεται να εξακολουθείτε να έχετε πρόσβαση μέσω άλλων κοινόχρηστων στοιχείων ή/και συμμετοχών σε χώρους."
#. ShareExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:34
msgid ""
"Your share to {ShareFolder} has expired at {ExpiredAt}\n"
"\n"
"Even though this share has been revoked you still might have access through other shares and/or space memberships."
msgstr ""
"Ο διαμοιρασμός σας στο {ShareFolder} έληξε στις {ExpiredAt}\n"
"\n"
"Παρόλο που αυτός ο διαμοιρασμός έχει ανακληθεί, ενδέχεται να εξακολουθείτε να έχετε πρόσβαση μέσω άλλων κοινόχρηστων στοιχείων ή/και συμμετοχών σε χώρους."
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .MessageBody }}
#: pkg/email/templates.go:106
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"Please visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
"Ο/Η {ShareSharer} ({ShareSharerMail}) επιθυμεί να ξεκινήσει τον διαμοιρασμό πόρων συνεργασίας μαζί σας.\n"
"Παρακαλούμε επισκεφθείτε τις ρυθμίσεις ομοσπονδίας (federation) και χρησιμοποιήστε τα ακόλουθα στοιχεία:\n"
" Διακριτικό: {Token}\n"
" Τομέας Παρόχου: {ProviderDomain}"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{
#. .MessageBody }}
#: pkg/email/templates.go:89
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"To accept the invite, please visit the following URL:\n"
"{ShareLink}\n"
"\n"
"Alternatively, you can visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
"Ο/Η {ShareSharer} ({ShareSharerMail}) επιθυμεί να ξεκινήσει τον διαμοιρασμό πόρων συνεργασίας μαζί σας.\n"
"Για να αποδεχτείτε την πρόσκληση, παρακαλούμε επισκεφθείτε την ακόλουθη διεύθυνση URL:\n"
"{ShareLink}\n"
"\n"
"Εναλλακτικά, μπορείτε να επισκεφθείτε τις ρυθμίσεις ομοσπονδίας και να χρησιμοποιήσετε τα ακόλουθα στοιχεία:\n"
" Διακριτικό: {Token}\n"
" Τομέας Παρόχου: {ProviderDomain}"
#. ShareCreated email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:21
msgid "{ShareSharer} has shared \"{ShareFolder}\" with you."
msgstr "Ο/Η {ShareSharer} διαμοιράστηκε το στοιχείο \"{ShareFolder}\" μαζί σας."
#. ShareCreated email template, Subject field (resolves directly)
#: pkg/email/templates.go:17
msgid "{ShareSharer} shared '{ShareFolder}' with you"
msgstr "Ο/Η {ShareSharer} διαμοιράστηκε το '{ShareFolder}' μαζί σας"
#. SharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:48
msgid "{SpaceSharer} has invited you to join \"{SpaceName}\"."
msgstr "Ο/Η {SpaceSharer} σας προσκάλεσε να συμμετάσχετε στο \"{SpaceName}\"."
#. UnsharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:61
msgid ""
"{SpaceSharer} has removed you from \"{SpaceName}\".\n"
"\n"
"You might still have access through your other groups or direct membership."
msgstr ""
"Ο/Η {SpaceSharer} σας αφαίρεσε από το \"{SpaceName}\".\n"
"\n"
"Ενδέχεται να εξακολουθείτε να έχετε πρόσβαση μέσω άλλων ομάδων ή απευθείας συμμετοχής."
#. SharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:44
msgid "{SpaceSharer} invited you to join {SpaceName}"
msgstr "Ο/Η {SpaceSharer} σας προσκάλεσε να συμμετάσχετε στο {SpaceName}"
#. UnsharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:57
msgid "{SpaceSharer} removed you from {SpaceName}"
msgstr "Ο/Η {SpaceSharer} σας αφαίρεσε από το {SpaceName}"
@@ -0,0 +1,160 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Elías Martín, 2025
# miguel tapias, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-05-06 00:01+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: miguel tapias, 2025\n"
"Language-Team: Spanish (https://app.transifex.com/qsfera-eu/teams/204053/es/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: es\n"
"Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n"
#. UnsharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:65
msgid "Click here to check it: {ShareLink}"
msgstr "Clic aquí para revisarlo: {ShareLink}"
#. ShareCreated email template, resolves via {{ .CallToAction }}
#. SharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:23 pkg/email/templates.go:50
msgid "Click here to view it: {ShareLink}"
msgstr "Clic aquí para verlo: {ShareLink}"
#. ShareCreated email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:19
msgid "Hello {ShareGrantee}"
msgstr "Hola {ShareGrantee}"
#. ShareExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:32
msgid "Hello {ShareGrantee},"
msgstr "Hola {ShareGrantee},"
#. SharedSpace email template, resolves via {{ .Greeting }}
#. UnsharedSpace email template, resolves via {{ .Greeting }}
#. MembershipExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:46 pkg/email/templates.go:59
#: pkg/email/templates.go:74
msgid "Hello {SpaceGrantee},"
msgstr "Hola {SpaceGrantee},"
#. Grouped email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:118
msgid "Hi {DisplayName},"
msgstr "Bienvenido {DisplayName},"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{ .Greeting
#. }}
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .Greeting }}
#: pkg/email/templates.go:87 pkg/email/templates.go:104
msgid "Hi,"
msgstr "¡Hola!,"
#. MembershipExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:72
msgid "Membership of '{SpaceName}' expired at {ExpiredAt}"
msgstr "La membresía de '{SpaceName}' expiró el {ExpiredAt}"
#. Grouped email template, Subject field (resolves directly)
#: pkg/email/templates.go:116
msgid "Report"
msgstr "Reporte"
#. ScienceMeshInviteTokenGenerated email template, Subject field (resolves
#. directly)
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, Subject
#. field (resolves directly)
#: pkg/email/templates.go:85 pkg/email/templates.go:102
msgid "ScienceMesh: {InitiatorName} wants to collaborate with you"
msgstr "ScienceMesh: {InitiatorName} quiere colaborar contigo"
#. ShareExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:30
msgid "Share to '{ShareFolder}' expired at {ExpiredAt}"
msgstr "La compartición de '{ShareFolder}' expiró el {ExpiredAt}"
#. MembershipExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:76
msgid ""
"Your membership of space {SpaceName} has expired at {ExpiredAt}\n"
"\n"
"Even though this membership has expired you still might have access through other shares and/or space memberships"
msgstr ""
#. ShareExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:34
msgid ""
"Your share to {ShareFolder} has expired at {ExpiredAt}\n"
"\n"
"Even though this share has been revoked you still might have access through other shares and/or space memberships."
msgstr ""
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .MessageBody }}
#: pkg/email/templates.go:106
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"Please visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
#. ScienceMeshInviteTokenGenerated email template, resolves via {{
#. .MessageBody }}
#: pkg/email/templates.go:89
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"To accept the invite, please visit the following URL:\n"
"{ShareLink}\n"
"\n"
"Alternatively, you can visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
#. ShareCreated email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:21
msgid "{ShareSharer} has shared \"{ShareFolder}\" with you."
msgstr "{ShareSharer} ha compartido \"{ShareFolder}\" contigo."
#. ShareCreated email template, Subject field (resolves directly)
#: pkg/email/templates.go:17
msgid "{ShareSharer} shared '{ShareFolder}' with you"
msgstr "{ShareSharer} compartió '{ShareFolder}' contigo"
#. SharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:48
msgid "{SpaceSharer} has invited you to join \"{SpaceName}\"."
msgstr "{SpaceSharer} te ha invitado a unirte a \"{SpaceName}\"."
#. UnsharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:61
msgid ""
"{SpaceSharer} has removed you from \"{SpaceName}\".\n"
"\n"
"You might still have access through your other groups or direct membership."
msgstr ""
#. SharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:44
msgid "{SpaceSharer} invited you to join {SpaceName}"
msgstr "{SpaceSharer} te invitó para unirte a {SpaceName}"
#. UnsharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:57
msgid "{SpaceSharer} removed you from {SpaceName}"
msgstr "{SpaceSharer} te eliminó de {SpaceName}"
@@ -0,0 +1,162 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Jiri Grönroos <jiri.gronroos@iki.fi>, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-05-07 00:02+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Jiri Grönroos <jiri.gronroos@iki.fi>, 2025\n"
"Language-Team: Finnish (https://app.transifex.com/qsfera-eu/teams/204053/fi/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: fi\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. UnsharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:65
msgid "Click here to check it: {ShareLink}"
msgstr "Napsauta tästä tarkistaaksesi sen: {ShareLink}"
#. ShareCreated email template, resolves via {{ .CallToAction }}
#. SharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:23 pkg/email/templates.go:50
msgid "Click here to view it: {ShareLink}"
msgstr "Napsauta tästä nähdäksesi sen: {ShareLink}"
#. ShareCreated email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:19
msgid "Hello {ShareGrantee}"
msgstr "Hei {ShareGrantee}"
#. ShareExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:32
msgid "Hello {ShareGrantee},"
msgstr "Hei {ShareGrantee}"
#. SharedSpace email template, resolves via {{ .Greeting }}
#. UnsharedSpace email template, resolves via {{ .Greeting }}
#. MembershipExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:46 pkg/email/templates.go:59
#: pkg/email/templates.go:74
msgid "Hello {SpaceGrantee},"
msgstr "Hei {SpaceGrantee}"
#. Grouped email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:118
msgid "Hi {DisplayName},"
msgstr "Hei {DisplayName}"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{ .Greeting
#. }}
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .Greeting }}
#: pkg/email/templates.go:87 pkg/email/templates.go:104
msgid "Hi,"
msgstr "Hei"
#. MembershipExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:72
msgid "Membership of '{SpaceName}' expired at {ExpiredAt}"
msgstr "Avaruuden '{SpaceName}' jäsenyys vanheni {ExpiredAt}"
#. Grouped email template, Subject field (resolves directly)
#: pkg/email/templates.go:116
msgid "Report"
msgstr "Ilmoita"
#. ScienceMeshInviteTokenGenerated email template, Subject field (resolves
#. directly)
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, Subject
#. field (resolves directly)
#: pkg/email/templates.go:85 pkg/email/templates.go:102
msgid "ScienceMesh: {InitiatorName} wants to collaborate with you"
msgstr "ScienceMesh: {InitiatorName} haluaa tehdä yhteistyötä kanssasi"
#. ShareExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:30
msgid "Share to '{ShareFolder}' expired at {ExpiredAt}"
msgstr "Jako kansioon '{ShareFolder}' vanheni {ExpiredAt}"
#. MembershipExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:76
msgid ""
"Your membership of space {SpaceName} has expired at {ExpiredAt}\n"
"\n"
"Even though this membership has expired you still might have access through other shares and/or space memberships"
msgstr ""
#. ShareExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:34
msgid ""
"Your share to {ShareFolder} has expired at {ExpiredAt}\n"
"\n"
"Even though this share has been revoked you still might have access through other shares and/or space memberships."
msgstr ""
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .MessageBody }}
#: pkg/email/templates.go:106
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"Please visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
#. ScienceMeshInviteTokenGenerated email template, resolves via {{
#. .MessageBody }}
#: pkg/email/templates.go:89
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"To accept the invite, please visit the following URL:\n"
"{ShareLink}\n"
"\n"
"Alternatively, you can visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
#. ShareCreated email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:21
msgid "{ShareSharer} has shared \"{ShareFolder}\" with you."
msgstr "{ShareSharer} on jakanut kansion \"{ShareFolder}\" kanssasi."
#. ShareCreated email template, Subject field (resolves directly)
#: pkg/email/templates.go:17
msgid "{ShareSharer} shared '{ShareFolder}' with you"
msgstr "{ShareSharer} jakoi '{ShareFolder}' kanssasi"
#. SharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:48
msgid "{SpaceSharer} has invited you to join \"{SpaceName}\"."
msgstr "{SpaceSharer} kutsui sinut avaruuteen \"{SpaceName}\"."
#. UnsharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:61
msgid ""
"{SpaceSharer} has removed you from \"{SpaceName}\".\n"
"\n"
"You might still have access through your other groups or direct membership."
msgstr ""
"{SpaceSharer} on poistanut sinut avaruudesta \"{SpaceName}\".\n"
"\n"
"Sinulla saattaa silti olla pääsy muiden ryhmien tai suoran jäsenyyden kautta."
#. SharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:44
msgid "{SpaceSharer} invited you to join {SpaceName}"
msgstr "{SpaceSharer} kutsui sinut avaruuteen {SpaceName}"
#. UnsharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:57
msgid "{SpaceSharer} removed you from {SpaceName}"
msgstr "{SpaceSharer} poisti sinut avaruudesta {SpaceName}"
@@ -0,0 +1,179 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# eric_G <junk.eg@free.fr>, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-22 00:03+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: eric_G <junk.eg@free.fr>, 2025\n"
"Language-Team: French (https://app.transifex.com/qsfera-eu/teams/204053/fr/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: fr\n"
"Plural-Forms: nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n"
#. UnsharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:65
msgid "Click here to check it: {ShareLink}"
msgstr "Cliquez ici pour le consulter : {ShareLink}"
#. ShareCreated email template, resolves via {{ .CallToAction }}
#. SharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:23 pkg/email/templates.go:50
msgid "Click here to view it: {ShareLink}"
msgstr "Cliquez ici pour le voir : {ShareLink}"
#. ShareCreated email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:19
msgid "Hello {ShareGrantee}"
msgstr "Bonjour {ShareGrantee}"
#. ShareExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:32
msgid "Hello {ShareGrantee},"
msgstr "Bonjour {ShareGrantee},"
#. SharedSpace email template, resolves via {{ .Greeting }}
#. UnsharedSpace email template, resolves via {{ .Greeting }}
#. MembershipExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:46 pkg/email/templates.go:59
#: pkg/email/templates.go:74
msgid "Hello {SpaceGrantee},"
msgstr "Bonjour {SpaceGrantee}"
#. Grouped email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:118
msgid "Hi {DisplayName},"
msgstr "Bonjour {DisplayName},"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{ .Greeting
#. }}
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .Greeting }}
#: pkg/email/templates.go:87 pkg/email/templates.go:104
msgid "Hi,"
msgstr "Bonjour,"
#. MembershipExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:72
msgid "Membership of '{SpaceName}' expired at {ExpiredAt}"
msgstr "L'affiliation à '{SpaceName}' a expiré à {ExpiredAt}"
#. Grouped email template, Subject field (resolves directly)
#: pkg/email/templates.go:116
msgid "Report"
msgstr "Rapport"
#. ScienceMeshInviteTokenGenerated email template, Subject field (resolves
#. directly)
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, Subject
#. field (resolves directly)
#: pkg/email/templates.go:85 pkg/email/templates.go:102
msgid "ScienceMesh: {InitiatorName} wants to collaborate with you"
msgstr "ScienceMesh : {InitiatorName} souhaite collaborer avec vous"
#. ShareExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:30
msgid "Share to '{ShareFolder}' expired at {ExpiredAt}"
msgstr "Le partage avec '{ShareFolder}' a expiré le {ExpiredAt}"
#. MembershipExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:76
msgid ""
"Your membership of space {SpaceName} has expired at {ExpiredAt}\n"
"\n"
"Even though this membership has expired you still might have access through other shares and/or space memberships"
msgstr ""
"Votre adhésion à l'espace {SpaceName} a expiré à {ExpiredAt}\n"
"\n"
"Même si cette adhésion a expiré, vous pouvez encore avoir accès à d'autres parts et/ou adhésions à l'espace."
#. ShareExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:34
msgid ""
"Your share to {ShareFolder} has expired at {ExpiredAt}\n"
"\n"
"Even though this share has been revoked you still might have access through other shares and/or space memberships."
msgstr ""
"Votre partage sur {ShareFolder} a expiré à {ExpiredAt}\n"
"\n"
"Même si ce partage a été révoqué, vous pouvez toujours y avoir accès par le biais d'autres partages et/ou adhésions à l'espace."
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .MessageBody }}
#: pkg/email/templates.go:106
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"Please visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
"{ShareSharer} ({ShareSharerMail}) souhaite commencer à partager des ressources de collaboration avec vous.\n"
"Veuillez consulter les paramètres de votre fédération et utiliser les informations suivantes :\n"
"Jeton : {Token}\n"
"Domaine du fournisseur : {ProviderDomain}"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{
#. .MessageBody }}
#: pkg/email/templates.go:89
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"To accept the invite, please visit the following URL:\n"
"{ShareLink}\n"
"\n"
"Alternatively, you can visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
"{ShareSharer} ({ShareSharerMail}) souhaite commencer à partager des ressources de collaboration avec vous.\n"
"Pour accepter l'invitation, veuillez visiter l'URL suivante :\n"
"{ShareLink}\n"
"\n"
"Vous pouvez également consulter les paramètres de votre fédération et utiliser les informations suivantes :\n"
"Jeton : {Token}\n"
"Domaine du fournisseur : {ProviderDomain}"
#. ShareCreated email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:21
msgid "{ShareSharer} has shared \"{ShareFolder}\" with you."
msgstr "{ShareSharer} a partagé « {ShareFolder} » avec vous."
#. ShareCreated email template, Subject field (resolves directly)
#: pkg/email/templates.go:17
msgid "{ShareSharer} shared '{ShareFolder}' with you"
msgstr "{ShareSharer} a partagé « {ShareFolder} » avec vous"
#. SharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:48
msgid "{SpaceSharer} has invited you to join \"{SpaceName}\"."
msgstr "{SpaceSharer} vous a invité à rejoindre « {SpaceName} »."
#. UnsharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:61
msgid ""
"{SpaceSharer} has removed you from \"{SpaceName}\".\n"
"\n"
"You might still have access through your other groups or direct membership."
msgstr ""
"{SpaceSharer} vous a retiré de « {SpaceName} ».\n"
"\n"
"Il se peut que vous y ayez encore accès par l'intermédiaire de vos autres groupes ou de votre adhésion directe."
#. SharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:44
msgid "{SpaceSharer} invited you to join {SpaceName}"
msgstr "{SpaceSharer} vous a invité à rejoindre {SpaceName}"
#. UnsharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:57
msgid "{SpaceSharer} removed you from {SpaceName}"
msgstr "{SpaceSharer} vous a retiré de {SpaceName}"
@@ -0,0 +1,182 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# mitibor, 2026
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-05-04 07:46+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: mitibor, 2026\n"
"Language-Team: Hungarian (https://app.transifex.com/qsfera-eu/teams/204053/hu/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: hu\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. UnsharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:65
msgid "Click here to check it: {ShareLink}"
msgstr "Kattintson ide az ellenőrzéshez: {ShareLink}"
#. ShareCreated email template, resolves via {{ .CallToAction }}
#. SharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:23 pkg/email/templates.go:50
msgid "Click here to view it: {ShareLink}"
msgstr "Kattintson ide a megtekintéshez: {ShareLink}"
#. ShareCreated email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:19
msgid "Hello {ShareGrantee}"
msgstr "Üdvözöljük, {ShareGrantee}"
#. ShareExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:32
msgid "Hello {ShareGrantee},"
msgstr "Kedves {ShareGrantee}!"
#. SharedSpace email template, resolves via {{ .Greeting }}
#. UnsharedSpace email template, resolves via {{ .Greeting }}
#. MembershipExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:46 pkg/email/templates.go:59
#: pkg/email/templates.go:74
msgid "Hello {SpaceGrantee},"
msgstr "Kedves {SpaceGrantee}!"
#. Grouped email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:118
msgid "Hi {DisplayName},"
msgstr "Szia {DisplayName}!"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{ .Greeting
#. }}
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .Greeting }}
#: pkg/email/templates.go:87 pkg/email/templates.go:104
msgid "Hi,"
msgstr "Üdvözöljük!"
#. MembershipExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:72
msgid "Membership of '{SpaceName}' expired at {ExpiredAt}"
msgstr "A(z) '{SpaceName}' tárhelytagsága lejárt ekkor: {ExpiredAt}"
#. Grouped email template, Subject field (resolves directly)
#: pkg/email/templates.go:116
msgid "Report"
msgstr "Jelentés"
#. ScienceMeshInviteTokenGenerated email template, Subject field (resolves
#. directly)
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, Subject
#. field (resolves directly)
#: pkg/email/templates.go:85 pkg/email/templates.go:102
msgid "ScienceMesh: {InitiatorName} wants to collaborate with you"
msgstr "ScienceMesh: {InitiatorName} együttműködést kezdeményezett Önnel"
#. ShareExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:30
msgid "Share to '{ShareFolder}' expired at {ExpiredAt}"
msgstr ""
"A(z) '{ShareFolder}' mappához tartozó megosztás lejárt ekkor: {ExpiredAt}"
#. MembershipExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:76
msgid ""
"Your membership of space {SpaceName} has expired at {ExpiredAt}\n"
"\n"
"Even though this membership has expired you still might have access through other shares and/or space memberships"
msgstr ""
"A(z) {SpaceName} tárhelyhez tartozó tagsága lejárt ekkor: {ExpiredAt}\n"
"\n"
"Bár ez a tagság lejárt, más megosztásokon vagy tárhelytagságokon keresztül továbbra is lehet hozzáférése."
#. ShareExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:34
msgid ""
"Your share to {ShareFolder} has expired at {ExpiredAt}\n"
"\n"
"Even though this share has been revoked you still might have access through other shares and/or space memberships."
msgstr ""
"A(z) {ShareFolder} mappához tartozó megosztása lejárt ekkor: {ExpiredAt}\n"
"\n"
"Bár ezt a megosztást visszavonták, más megosztásokon vagy tárhelytagságokon keresztül továbbra is lehet hozzáférése."
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .MessageBody }}
#: pkg/email/templates.go:106
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"Please visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
"{ShareSharer} ({ShareSharerMail}) erőforrásokat szeretne megosztani Önnel az együttműködéshez.\n"
"Kérjük, látogasson el a föderációs beállításokhoz, és használja az alábbi adatokat:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{
#. .MessageBody }}
#: pkg/email/templates.go:89
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"To accept the invite, please visit the following URL:\n"
"{ShareLink}\n"
"\n"
"Alternatively, you can visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
"{ShareSharer} ({ShareSharerMail}) erőforrásokat szeretne megosztani Önnel az együttműködéshez.\n"
"A meghívó elfogadásához kérjük, látogasson el az alábbi weboldalra:\n"
"{ShareLink}\n"
"\n"
"Alternatív megoldásként a föderációs beállításoknál is megadhatja az alábbi adatokat:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
#. ShareCreated email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:21
msgid "{ShareSharer} has shared \"{ShareFolder}\" with you."
msgstr "{ShareSharer} megosztotta Önnel a(z) \"{ShareFolder}\" mappát."
#. ShareCreated email template, Subject field (resolves directly)
#: pkg/email/templates.go:17
msgid "{ShareSharer} shared '{ShareFolder}' with you"
msgstr "{ShareSharer} megosztotta Önnel a(z) '{ShareFolder}' mappát"
#. SharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:48
msgid "{SpaceSharer} has invited you to join \"{SpaceName}\"."
msgstr ""
"{SpaceSharer} meghívta Önt a(z) \"{SpaceName}\" tárhelyhez való "
"csatlakozásra."
#. UnsharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:61
msgid ""
"{SpaceSharer} has removed you from \"{SpaceName}\".\n"
"\n"
"You might still have access through your other groups or direct membership."
msgstr ""
"{SpaceSharer} eltávolította Önt a(z) \"{SpaceName}\" tárhelyről.\n"
"\n"
"Más csoportokon vagy közvetlen tagságon keresztül továbbra is lehet hozzáférése."
#. SharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:44
msgid "{SpaceSharer} invited you to join {SpaceName}"
msgstr "{SpaceSharer} meghívta Önt a(z) {SpaceName} tárhelyhez"
#. UnsharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:57
msgid "{SpaceSharer} removed you from {SpaceName}"
msgstr "{SpaceSharer} eltávolította Önt a(z) {SpaceName} tárhelyről"
@@ -0,0 +1,181 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Simone Broglia, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-22 00:03+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Simone Broglia, 2025\n"
"Language-Team: Italian (https://app.transifex.com/qsfera-eu/teams/204053/it/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: it\n"
"Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n"
#. UnsharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:65
msgid "Click here to check it: {ShareLink}"
msgstr "Clicca qui per verificarlo: {ShareLink}"
#. ShareCreated email template, resolves via {{ .CallToAction }}
#. SharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:23 pkg/email/templates.go:50
msgid "Click here to view it: {ShareLink}"
msgstr "Clicca qui per visualizzarlo: {ShareLink}"
#. ShareCreated email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:19
msgid "Hello {ShareGrantee}"
msgstr "Ciao {ShareGrantee}"
#. ShareExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:32
msgid "Hello {ShareGrantee},"
msgstr "Ciao {ShareGrantee},"
#. SharedSpace email template, resolves via {{ .Greeting }}
#. UnsharedSpace email template, resolves via {{ .Greeting }}
#. MembershipExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:46 pkg/email/templates.go:59
#: pkg/email/templates.go:74
msgid "Hello {SpaceGrantee},"
msgstr "Ciao {SpaceGrantee},"
#. Grouped email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:118
msgid "Hi {DisplayName},"
msgstr "Ciao {DisplayName},"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{ .Greeting
#. }}
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .Greeting }}
#: pkg/email/templates.go:87 pkg/email/templates.go:104
msgid "Hi,"
msgstr "Ciao,"
#. MembershipExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:72
msgid "Membership of '{SpaceName}' expired at {ExpiredAt}"
msgstr "L'iscrizione a '{SpaceName}' è scaduta il {ExpiredAt}"
#. Grouped email template, Subject field (resolves directly)
#: pkg/email/templates.go:116
msgid "Report"
msgstr "Rapporto"
#. ScienceMeshInviteTokenGenerated email template, Subject field (resolves
#. directly)
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, Subject
#. field (resolves directly)
#: pkg/email/templates.go:85 pkg/email/templates.go:102
msgid "ScienceMesh: {InitiatorName} wants to collaborate with you"
msgstr "ScienceMesh: {InitiatorName} vuole collaborare con te"
#. ShareExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:30
msgid "Share to '{ShareFolder}' expired at {ExpiredAt}"
msgstr "La condivisione con '{ShareFolder}' è scaduta il {ExpiredAt}"
#. MembershipExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:76
msgid ""
"Your membership of space {SpaceName} has expired at {ExpiredAt}\n"
"\n"
"Even though this membership has expired you still might have access through other shares and/or space memberships"
msgstr ""
"La tua iscrizione allo spazio {SpaceName} è scaduta il {ExpiredAt}.\n"
"\n"
"Tuttavia, potresti ancora avere accesso tramite altre condivisioni e/o iscrizioni ad altri spazi"
#. ShareExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:34
msgid ""
"Your share to {ShareFolder} has expired at {ExpiredAt}\n"
"\n"
"Even though this share has been revoked you still might have access through other shares and/or space memberships."
msgstr ""
"La tua condivisione con {ShareFolder} è scaduta il {ExpiredAt}.\n"
"\n"
"Tuttavia, potresti ancora avere accesso tramite altre condivisioni e/o iscrizioni ad altri spazi"
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .MessageBody }}
#: pkg/email/templates.go:106
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"Please visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
"{ShareSharer} ({ShareSharerMail}) desidera iniziare a condividere risorse di collaborazione con te.\n"
"Visita le impostazioni di federazione e utilizza i seguenti dettagli:\n"
"\n"
"Token: {Token}\n"
"Dominio del provider: {ProviderDomain}"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{
#. .MessageBody }}
#: pkg/email/templates.go:89
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"To accept the invite, please visit the following URL:\n"
"{ShareLink}\n"
"\n"
"Alternatively, you can visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
"{ShareSharer} ({ShareSharerMail}) desidera iniziare a condividere risorse di collaborazione con te.\n"
"Per accettare l'invito, visita il seguente URL:\n"
"{ShareLink}\n"
"\n"
"In alternativa, puoi accedere alle impostazioni di federazione e utilizzare i seguenti dettagli:\n"
"\n"
"Token: {Token}\n"
"Dominio del provider: {ProviderDomain}"
#. ShareCreated email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:21
msgid "{ShareSharer} has shared \"{ShareFolder}\" with you."
msgstr "{ShareSharer} ha condiviso \"{ShareFolder}\" con te."
#. ShareCreated email template, Subject field (resolves directly)
#: pkg/email/templates.go:17
msgid "{ShareSharer} shared '{ShareFolder}' with you"
msgstr "{ShareSharer} ha condiviso '{ShareFolder}' con te"
#. SharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:48
msgid "{SpaceSharer} has invited you to join \"{SpaceName}\"."
msgstr "{SpaceSharer} ti ha invitato a unirti a \"{SpaceName}\"."
#. UnsharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:61
msgid ""
"{SpaceSharer} has removed you from \"{SpaceName}\".\n"
"\n"
"You might still have access through your other groups or direct membership."
msgstr ""
"{SpaceSharer} ti ha rimosso da \"{SpaceName}\".\n"
"\n"
"Potresti comunque avere accesso tramite altri gruppi o iscrizioni dirette."
#. SharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:44
msgid "{SpaceSharer} invited you to join {SpaceName}"
msgstr "{SpaceSharer} ti ha invitato a unirti a {SpaceName}"
#. UnsharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:57
msgid "{SpaceSharer} removed you from {SpaceName}"
msgstr "{SpaceSharer} ti ha rimosso da {SpaceName}"
@@ -0,0 +1,176 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# iikaka88, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-19 00:04+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: iikaka88, 2025\n"
"Language-Team: Japanese (https://app.transifex.com/qsfera-eu/teams/204053/ja/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: ja\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#. UnsharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:65
msgid "Click here to check it: {ShareLink}"
msgstr "こちらをクリックして確認してください: {ShareLink}"
#. ShareCreated email template, resolves via {{ .CallToAction }}
#. SharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:23 pkg/email/templates.go:50
msgid "Click here to view it: {ShareLink}"
msgstr "こちらをクリックして表示してください: {ShareLink}"
#. ShareCreated email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:19
msgid "Hello {ShareGrantee}"
msgstr "{Grantee} 様"
#. ShareExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:32
msgid "Hello {ShareGrantee},"
msgstr "{Grantee} 様"
#. SharedSpace email template, resolves via {{ .Greeting }}
#. UnsharedSpace email template, resolves via {{ .Greeting }}
#. MembershipExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:46 pkg/email/templates.go:59
#: pkg/email/templates.go:74
msgid "Hello {SpaceGrantee},"
msgstr "{Grantee} 様"
#. Grouped email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:118
msgid "Hi {DisplayName},"
msgstr "{DisplayName} さん、"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{ .Greeting
#. }}
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .Greeting }}
#: pkg/email/templates.go:87 pkg/email/templates.go:104
msgid "Hi,"
msgstr "こんにちは"
#. MembershipExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:72
msgid "Membership of '{SpaceName}' expired at {ExpiredAt}"
msgstr "「{SpaceName}」のメンバーシップ期限が {ExpiredAt} に終了しました"
#. Grouped email template, Subject field (resolves directly)
#: pkg/email/templates.go:116
msgid "Report"
msgstr "レポート"
#. ScienceMeshInviteTokenGenerated email template, Subject field (resolves
#. directly)
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, Subject
#. field (resolves directly)
#: pkg/email/templates.go:85 pkg/email/templates.go:102
msgid "ScienceMesh: {InitiatorName} wants to collaborate with you"
msgstr "ScienceMesh: {InitiatorName} があなたとのコラボレーションを希望しています"
#. ShareExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:30
msgid "Share to '{ShareFolder}' expired at {ExpiredAt}"
msgstr "「{ShareFolder}」の共有期限が {ExpiredAt} に終了しました"
#. MembershipExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:76
msgid ""
"Your membership of space {SpaceName} has expired at {ExpiredAt}\n"
"\n"
"Even though this membership has expired you still might have access through other shares and/or space memberships"
msgstr ""
"スペース「{SpaceName}」のメンバーシップ期限が {ExpiredAt} に終了しました\n"
"\n"
"期限は終了しましたが、他の共有やメンバーシップを通じて引き続きアクセスできる場合があります"
#. ShareExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:34
msgid ""
"Your share to {ShareFolder} has expired at {ExpiredAt}\n"
"\n"
"Even though this share has been revoked you still might have access through other shares and/or space memberships."
msgstr ""
"「{ShareFolder}」の共有期限が {ExpiredAt} に終了しました\n"
"\n"
"共有は解除されましたが、他の共有やメンバーシップを通じて引き続きアクセスできる場合があります"
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .MessageBody }}
#: pkg/email/templates.go:106
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"Please visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
"{ShareSharer} ({ShareSharerMail}) があなたとリソースの共有を希望しています\n"
"フェデレーション設定に移動し、以下の情報を入力してください\n"
"トークン: {Token} プロバイダドメイン: {ProviderDomain}"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{
#. .MessageBody }}
#: pkg/email/templates.go:89
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"To accept the invite, please visit the following URL:\n"
"{ShareLink}\n"
"\n"
"Alternatively, you can visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
"{ShareSharer} ({ShareSharerMail}) があなたとリソースの共有を希望しています。\n"
"招待を承諾するには、以下のURLにアクセスしてください。 {ShareLink}\n"
"\n"
"または、フェデレーション設定で以下の情報を使用することもできます。 \n"
"トークン: {Token} プロバイダドメイン: {ProviderDomain}"
#. ShareCreated email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:21
msgid "{ShareSharer} has shared \"{ShareFolder}\" with you."
msgstr "{ShareSharer} が「{ShareFolder}」を共有しました"
#. ShareCreated email template, Subject field (resolves directly)
#: pkg/email/templates.go:17
msgid "{ShareSharer} shared '{ShareFolder}' with you"
msgstr "{ShareSharer} が「{ShareFolder}」を共有しました"
#. SharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:48
msgid "{SpaceSharer} has invited you to join \"{SpaceName}\"."
msgstr "{SpaceSharer} があなたを「{SpaceName}」に招待しました"
#. UnsharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:61
msgid ""
"{SpaceSharer} has removed you from \"{SpaceName}\".\n"
"\n"
"You might still have access through your other groups or direct membership."
msgstr ""
"{SpaceSharer} があなたを「{SpaceName}」から削除しました。\n"
"\n"
"他のグループや直接のメンバーシップを通じて、引き続きアクセスできる可能性があります。"
#. SharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:44
msgid "{SpaceSharer} invited you to join {SpaceName}"
msgstr "{SpaceSharer} があなたを「{SpaceName}」に招待しました"
#. UnsharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:57
msgid "{SpaceSharer} removed you from {SpaceName}"
msgstr "{SpaceSharer} があなたを「{SpaceName}」から削除しました。"
@@ -0,0 +1,179 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# gapho shin, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-22 00:03+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: gapho shin, 2025\n"
"Language-Team: Korean (https://app.transifex.com/qsfera-eu/teams/204053/ko/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: ko\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#. UnsharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:65
msgid "Click here to check it: {ShareLink}"
msgstr "여기를 클릭하여 확인하세요: {ShareLink}"
#. ShareCreated email template, resolves via {{ .CallToAction }}
#. SharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:23 pkg/email/templates.go:50
msgid "Click here to view it: {ShareLink}"
msgstr "여기를 클릭하여 확인하세요: {ShareLink}"
#. ShareCreated email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:19
msgid "Hello {ShareGrantee}"
msgstr "안녕하세요 {ShareGrantee}"
#. ShareExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:32
msgid "Hello {ShareGrantee},"
msgstr "안녕하세요 {ShareGrantee},"
#. SharedSpace email template, resolves via {{ .Greeting }}
#. UnsharedSpace email template, resolves via {{ .Greeting }}
#. MembershipExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:46 pkg/email/templates.go:59
#: pkg/email/templates.go:74
msgid "Hello {SpaceGrantee},"
msgstr "안녕하세요 {SpaceGrantee},"
#. Grouped email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:118
msgid "Hi {DisplayName},"
msgstr "안녕하세요 {DisplayName},"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{ .Greeting
#. }}
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .Greeting }}
#: pkg/email/templates.go:87 pkg/email/templates.go:104
msgid "Hi,"
msgstr "안녕하세요"
#. MembershipExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:72
msgid "Membership of '{SpaceName}' expired at {ExpiredAt}"
msgstr "'{SpaceName}'의 멤버십이 {ExpireDat}에서 만료되었습니다"
#. Grouped email template, Subject field (resolves directly)
#: pkg/email/templates.go:116
msgid "Report"
msgstr "리포트"
#. ScienceMeshInviteTokenGenerated email template, Subject field (resolves
#. directly)
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, Subject
#. field (resolves directly)
#: pkg/email/templates.go:85 pkg/email/templates.go:102
msgid "ScienceMesh: {InitiatorName} wants to collaborate with you"
msgstr "ScienceMesh: {InitiatorName}이/가 여러분과 협력하고 싶습니다"
#. ShareExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:30
msgid "Share to '{ShareFolder}' expired at {ExpiredAt}"
msgstr "'{ShareFolder}'의 공유가 {ExpireDat}에서 만료됨"
#. MembershipExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:76
msgid ""
"Your membership of space {SpaceName} has expired at {ExpiredAt}\n"
"\n"
"Even though this membership has expired you still might have access through other shares and/or space memberships"
msgstr ""
"{SpaceName} 의 멤버십이 {ExpireDat}에서 만료되었습니다\n"
"\n"
"이 멤버십이 만료되었더라도 다른 공유 또는 스페이스 멤버십을 통해 액세스할 수 있습니다"
#. ShareExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:34
msgid ""
"Your share to {ShareFolder} has expired at {ExpiredAt}\n"
"\n"
"Even though this share has been revoked you still might have access through other shares and/or space memberships."
msgstr ""
"{ShareFolder}에 대한 공유가 {ExpireDat}에서 만료되었습니다\n"
"\n"
"이 공유가 취소되더라도 다른 공유 또는 스페이스 멤버십을 통해 액세스할 수 있습니다."
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .MessageBody }}
#: pkg/email/templates.go:106
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"Please visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
"{ShareSharer} ({ShareSharerMail}) 가 여러분과 협업 리소스를 공유하고자 합니다.\n"
"공통 설정을 방문하시고 다음 세부 정보를 확인 바랍니다:\n"
" 토큰: {Token}\n"
" 공급자 도메인: {ProviderDomain}"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{
#. .MessageBody }}
#: pkg/email/templates.go:89
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"To accept the invite, please visit the following URL:\n"
"{ShareLink}\n"
"\n"
"Alternatively, you can visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
"{ShareSharer}({ShareSharerMail})가 여러분과 협업 리소스를 공유하고자 합니다.\n"
"초대를 수락하려면 다음 URL을 방문하세요:\n"
"{ShareLink}\n"
"\n"
"또는 공통 설정을 방문하시고 다음 세부 정보를 확인 바랍니다:\n"
"토큰: {Token}\n"
"공급자 도메인: {ProviderDomain}"
#. ShareCreated email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:21
msgid "{ShareSharer} has shared \"{ShareFolder}\" with you."
msgstr "{ShareSharer}에서 \"{ShareFolder}\"를 공유했습니다."
#. ShareCreated email template, Subject field (resolves directly)
#: pkg/email/templates.go:17
msgid "{ShareSharer} shared '{ShareFolder}' with you"
msgstr "{ShareSharer}가 '{ShareFolder}'를 공유했습니다"
#. SharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:48
msgid "{SpaceSharer} has invited you to join \"{SpaceName}\"."
msgstr "{SpaceSharer}에서 \"{SpaceName}\"에 참여하도록 초대했습니다."
#. UnsharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:61
msgid ""
"{SpaceSharer} has removed you from \"{SpaceName}\".\n"
"\n"
"You might still have access through your other groups or direct membership."
msgstr ""
"{SpaceSharer}가 사용자를 \"{SpaceName}\"에서 삭제했습니다.\n"
"\n"
"다른 그룹이나 직접 멤버십을 통해서도 액세스할 수 있습니다."
#. SharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:44
msgid "{SpaceSharer} invited you to join {SpaceName}"
msgstr "{SpaceSharer}가 여러분을 {SpaceName}에 초대했습니다"
#. UnsharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:57
msgid "{SpaceSharer} removed you from {SpaceName}"
msgstr "{SpaceSharer}가 {SpaceName}에서 사용자를 삭제했습니다"
@@ -0,0 +1,179 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# BoneNI, 2026
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-30 00:02+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: BoneNI, 2026\n"
"Language-Team: Lao (https://app.transifex.com/qsfera-eu/teams/204053/lo/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: lo\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#. UnsharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:65
msgid "Click here to check it: {ShareLink}"
msgstr "ກົດບ່ອນນີ້ເພື່ອເຂົ້າໄປກວດສອບ: {ShareLink}"
#. ShareCreated email template, resolves via {{ .CallToAction }}
#. SharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:23 pkg/email/templates.go:50
msgid "Click here to view it: {ShareLink}"
msgstr "ກົດບ່ອນນີ້ເພື່ອເບິ່ງ: {ShareLink}"
#. ShareCreated email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:19
msgid "Hello {ShareGrantee}"
msgstr "ສະບາຍດີ {ShareGrantee}"
#. ShareExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:32
msgid "Hello {ShareGrantee},"
msgstr "ສະບາຍດີ {ShareGrantee},"
#. SharedSpace email template, resolves via {{ .Greeting }}
#. UnsharedSpace email template, resolves via {{ .Greeting }}
#. MembershipExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:46 pkg/email/templates.go:59
#: pkg/email/templates.go:74
msgid "Hello {SpaceGrantee},"
msgstr "ສະບາຍດີ {SpaceGrantee},"
#. Grouped email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:118
msgid "Hi {DisplayName},"
msgstr "ສະບາຍດີ {DisplayName},"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{ .Greeting
#. }}
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .Greeting }}
#: pkg/email/templates.go:87 pkg/email/templates.go:104
msgid "Hi,"
msgstr "ສະບາຍດີ,"
#. MembershipExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:72
msgid "Membership of '{SpaceName}' expired at {ExpiredAt}"
msgstr "ການເປັນສະມາຊິກຂອງ '{SpaceName}' ໝົດອາຍຸເມື່ອ {ExpiredAt}"
#. Grouped email template, Subject field (resolves directly)
#: pkg/email/templates.go:116
msgid "Report"
msgstr "ລາຍງານ"
#. ScienceMeshInviteTokenGenerated email template, Subject field (resolves
#. directly)
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, Subject
#. field (resolves directly)
#: pkg/email/templates.go:85 pkg/email/templates.go:102
msgid "ScienceMesh: {InitiatorName} wants to collaborate with you"
msgstr "ScienceMesh: {InitiatorName} ຕ້ອງການຮ່ວມມືກັບທ່ານ"
#. ShareExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:30
msgid "Share to '{ShareFolder}' expired at {ExpiredAt}"
msgstr "ການແບ່ງປັນໄປຍັງ '{ShareFolder}' ໝົດອາຍຸເມື່ອ {ExpiredAt}"
#. MembershipExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:76
msgid ""
"Your membership of space {SpaceName} has expired at {ExpiredAt}\n"
"\n"
"Even though this membership has expired you still might have access through other shares and/or space memberships"
msgstr ""
"ການເປັນສະມາຊິກພື້ນທີ່ {SpaceName} ຂອງທ່ານໝົດອາຍຸເມື່ອ {ExpiredAt}\n"
"\n"
"ເຖິງແມ່ນວ່າການເປັນສະມາຊິກນີ້ໝົດອາຍຸແລ້ວ ແຕ່ທ່ານອາດຈະຍັງສາມາດເຂົ້າເຖິງໄດ້ຜ່ານການແບ່ງປັນອື່ນໆ ແລະ/ຫຼື ການເປັນສະມາຊິກພື້ນທີ່ອື່ນ."
#. ShareExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:34
msgid ""
"Your share to {ShareFolder} has expired at {ExpiredAt}\n"
"\n"
"Even though this share has been revoked you still might have access through other shares and/or space memberships."
msgstr ""
"ການແບ່ງປັນ {ShareFolder} ຂອງທ່ານໝົດອາຍຸເມື່ອ {ExpiredAt}\n"
"\n"
"ເຖິງແມ່ນວ່າການແບ່ງປັນນີ້ຈະຖືກຍົກເລີກແລ້ວ ແຕ່ທ່ານອາດຈະຍັງສາມາດເຂົ້າເຖິງໄດ້ຜ່ານການແບ່ງປັນອື່ນໆ ແລະ/ຫຼື ການເປັນສະມາຊິກພື້ນທີ່."
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .MessageBody }}
#: pkg/email/templates.go:106
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"Please visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
"{ShareSharer} ({ShareSharerMail}) ຕ້ອງການເລີ່ມແບ່ງປັນຊັບພະຍາກອນການຮ່ວມມືກັບທ່ານ.\n"
"ກະລຸນາເຂົ້າໄປທີ່ການຕັ້ງຄ່າສະຫະພັນ (federation) ຂອງທ່ານ ແລະ ໃຊ້ລາຍລະອຽດດັ່ງນີ້:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{
#. .MessageBody }}
#: pkg/email/templates.go:89
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"To accept the invite, please visit the following URL:\n"
"{ShareLink}\n"
"\n"
"Alternatively, you can visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
"{ShareSharer} ({ShareSharerMail}) ຕ້ອງການເລີ່ມແບ່ງປັນຊັບພະຍາກອນການຮ່ວມມືກັບທ່ານ.\n"
"ເພື່ອຕອບຮັບຄຳເຊີນ, ກະລຸນາເຂົ້າໄປທີ່ URL ດັ່ງນີ້:\n"
"{ShareLink}\n"
"\n"
"ຫຼື ທ່ານສາມາດເຂົ້າໄປທີ່ການຕັ້ງຄ່າສະຫະພັນ (federation) ຂອງທ່ານ ແລະ ໃຊ້ລາຍລະອຽດດັ່ງນີ້:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
#. ShareCreated email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:21
msgid "{ShareSharer} has shared \"{ShareFolder}\" with you."
msgstr "{ShareSharer} ໄດ້ແບ່ງປັນ \"{ShareFolder}\" ກັບທ່ານ."
#. ShareCreated email template, Subject field (resolves directly)
#: pkg/email/templates.go:17
msgid "{ShareSharer} shared '{ShareFolder}' with you"
msgstr "{ShareSharer} ໄດ້ແບ່ງປັນ '{ShareFolder}' ກັບທ່ານ"
#. SharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:48
msgid "{SpaceSharer} has invited you to join \"{SpaceName}\"."
msgstr "{SpaceSharer} ໄດ້ເຊີນທ່ານເຂົ້າຮ່ວມ \"{SpaceName}\"."
#. UnsharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:61
msgid ""
"{SpaceSharer} has removed you from \"{SpaceName}\".\n"
"\n"
"You might still have access through your other groups or direct membership."
msgstr ""
"{SpaceSharer} ໄດ້ຍ້າຍທ່ານອອກຈາກ \"{SpaceName}\".\n"
"\n"
"ທ່ານອາດຈະຍັງສາມາດເຂົ້າເຖິງໄດ້ຜ່ານກຸ່ມອື່ນໆ ຫຼື ການເປັນສະມາຊິກໂດຍກົງ."
#. SharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:44
msgid "{SpaceSharer} invited you to join {SpaceName}"
msgstr "{SpaceSharer} ໄດ້ເຊີນທ່ານເຂົ້າຮ່ວມ {SpaceName}"
#. UnsharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:57
msgid "{SpaceSharer} removed you from {SpaceName}"
msgstr "{SpaceSharer} ໄດ້ຍ້າຍທ່ານອອກຈາກ {SpaceName}"
@@ -0,0 +1,179 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Stephan Paternotte <stephan@paternottes.net>, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-05-06 00:01+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Stephan Paternotte <stephan@paternottes.net>, 2025\n"
"Language-Team: Dutch (https://app.transifex.com/qsfera-eu/teams/204053/nl/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: nl\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. UnsharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:65
msgid "Click here to check it: {ShareLink}"
msgstr "Klik hier om het te controleren: {ShareLink}"
#. ShareCreated email template, resolves via {{ .CallToAction }}
#. SharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:23 pkg/email/templates.go:50
msgid "Click here to view it: {ShareLink}"
msgstr "Klik hier om het te bekijken: {ShareLink}"
#. ShareCreated email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:19
msgid "Hello {ShareGrantee}"
msgstr "Beste {ShareGrantee}"
#. ShareExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:32
msgid "Hello {ShareGrantee},"
msgstr "Beste {ShareGrantee},"
#. SharedSpace email template, resolves via {{ .Greeting }}
#. UnsharedSpace email template, resolves via {{ .Greeting }}
#. MembershipExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:46 pkg/email/templates.go:59
#: pkg/email/templates.go:74
msgid "Hello {SpaceGrantee},"
msgstr "Beste {SpaceGrantee},"
#. Grouped email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:118
msgid "Hi {DisplayName},"
msgstr "Hallo {DisplayName},"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{ .Greeting
#. }}
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .Greeting }}
#: pkg/email/templates.go:87 pkg/email/templates.go:104
msgid "Hi,"
msgstr "Hallo,"
#. MembershipExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:72
msgid "Membership of '{SpaceName}' expired at {ExpiredAt}"
msgstr "Lidmaatschap van '{SpaceName}' is verlopen op {ExpiredAt}"
#. Grouped email template, Subject field (resolves directly)
#: pkg/email/templates.go:116
msgid "Report"
msgstr "Rapportage"
#. ScienceMeshInviteTokenGenerated email template, Subject field (resolves
#. directly)
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, Subject
#. field (resolves directly)
#: pkg/email/templates.go:85 pkg/email/templates.go:102
msgid "ScienceMesh: {InitiatorName} wants to collaborate with you"
msgstr "ScienceMesh: {InitiatorName} wil met je samenwerken"
#. ShareExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:30
msgid "Share to '{ShareFolder}' expired at {ExpiredAt}"
msgstr "Share '{ShareFolder}' is verlopen op {ExpiredAt}"
#. MembershipExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:76
msgid ""
"Your membership of space {SpaceName} has expired at {ExpiredAt}\n"
"\n"
"Even though this membership has expired you still might have access through other shares and/or space memberships"
msgstr ""
"Jouw lidmaatschap van ruimte {SpaceName} is verlopen op {ExpiredAt}\n"
"\n"
"Hoewel dit lidmaatschap is verlopen, kun je mogelijk nog steeds toegang hebben via andere shares en/of ruimtelidmaatschappen."
#. ShareExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:34
msgid ""
"Your share to {ShareFolder} has expired at {ExpiredAt}\n"
"\n"
"Even though this share has been revoked you still might have access through other shares and/or space memberships."
msgstr ""
"Jouw toegang tot {ShareFolder} is verlopen op {ExpiredAt}\n"
"\n"
"Hoewel de toegang is ingetrokken, kun je mogelijk nog steeds toegang hebben via andere shares en/of ruimtelidmaatschappen."
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .MessageBody }}
#: pkg/email/templates.go:106
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"Please visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
"{ShareSharer} ({ShareSharerMail}) wil samenwerkingsbronnen met je delen.\n"
"Open je federatie-instellingen en gebruik de volgende gegevens.:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{
#. .MessageBody }}
#: pkg/email/templates.go:89
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"To accept the invite, please visit the following URL:\n"
"{ShareLink}\n"
"\n"
"Alternatively, you can visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
"{ShareSharer} ({ShareSharerMail}) wil samenwerkingsbronnen met je delen.\n"
"Open de volgende URL om de uitnodiging te accepteren:\n"
"{ShareLink}\n"
"\n"
"Alternatief kun je je federatie-instellingen openen en de volgende gegevens gebruiken.:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
#. ShareCreated email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:21
msgid "{ShareSharer} has shared \"{ShareFolder}\" with you."
msgstr "{ShareSharer} heeft \"{ShareFolder}\" met jou gedeeld."
#. ShareCreated email template, Subject field (resolves directly)
#: pkg/email/templates.go:17
msgid "{ShareSharer} shared '{ShareFolder}' with you"
msgstr "{ShareSharer} deelt '{ShareFolder}' met jou"
#. SharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:48
msgid "{SpaceSharer} has invited you to join \"{SpaceName}\"."
msgstr "{SpaceSharer} heeft je uitgenodigd om deel te nemen in \"{SpaceName}\"."
#. UnsharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:61
msgid ""
"{SpaceSharer} has removed you from \"{SpaceName}\".\n"
"\n"
"You might still have access through your other groups or direct membership."
msgstr ""
"{SpaceSharer} heeft jou verwijderd uit \"{SpaceName}\".\n"
"\n"
"Je hebt mogelijk nog steeds toegang via andere groepen of directe lidmaatschap."
#. SharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:44
msgid "{SpaceSharer} invited you to join {SpaceName}"
msgstr "{SpaceSharer} heeft jou uitgenodigd om deel te nemen in {SpaceName}"
#. UnsharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:57
msgid "{SpaceSharer} removed you from {SpaceName}"
msgstr "{SpaceSharer} heeft jou verwijderd uit {SpaceName}"
@@ -0,0 +1,177 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Mário Machado, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-05-06 00:01+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Mário Machado, 2025\n"
"Language-Team: Portuguese (https://app.transifex.com/qsfera-eu/teams/204053/pt/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: pt\n"
"Plural-Forms: nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n"
#. UnsharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:65
msgid "Click here to check it: {ShareLink}"
msgstr "Clique aqui para verificar: {ShareLink}"
#. ShareCreated email template, resolves via {{ .CallToAction }}
#. SharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:23 pkg/email/templates.go:50
msgid "Click here to view it: {ShareLink}"
msgstr "Clique aqui para visualizar: {ShareLink}"
#. ShareCreated email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:19
msgid "Hello {ShareGrantee}"
msgstr "Olá {ShareGrantee}"
#. ShareExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:32
msgid "Hello {ShareGrantee},"
msgstr "Olá {ShareGrantee},"
#. SharedSpace email template, resolves via {{ .Greeting }}
#. UnsharedSpace email template, resolves via {{ .Greeting }}
#. MembershipExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:46 pkg/email/templates.go:59
#: pkg/email/templates.go:74
msgid "Hello {SpaceGrantee},"
msgstr "Olá {SpaceGrantee},"
#. Grouped email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:118
msgid "Hi {DisplayName},"
msgstr "Olá {DisplayName},"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{ .Greeting
#. }}
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .Greeting }}
#: pkg/email/templates.go:87 pkg/email/templates.go:104
msgid "Hi,"
msgstr "Olá,"
#. MembershipExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:72
msgid "Membership of '{SpaceName}' expired at {ExpiredAt}"
msgstr "A subscrição em «{SpaceName}» expirou em {ExpiredAt}"
#. Grouped email template, Subject field (resolves directly)
#: pkg/email/templates.go:116
msgid "Report"
msgstr "Relatório"
#. ScienceMeshInviteTokenGenerated email template, Subject field (resolves
#. directly)
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, Subject
#. field (resolves directly)
#: pkg/email/templates.go:85 pkg/email/templates.go:102
msgid "ScienceMesh: {InitiatorName} wants to collaborate with you"
msgstr "ScienceMesh: {InitiatorName} quer colaborar consigo"
#. ShareExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:30
msgid "Share to '{ShareFolder}' expired at {ExpiredAt}"
msgstr "A partilha para «{ShareFolder}» expirou em {ExpiredAt}"
#. MembershipExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:76
msgid ""
"Your membership of space {SpaceName} has expired at {ExpiredAt}\n"
"\n"
"Even though this membership has expired you still might have access through other shares and/or space memberships"
msgstr ""
"A sua subscrição no espaço {SpaceName} expirou em {ExpiredAt}\n"
"\n"
"Ainda que esta subscrição tenha expirado, poderá continuar a ter acesso através de outras partilhas e/ou subscrições de espaço"
#. ShareExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:34
msgid ""
"Your share to {ShareFolder} has expired at {ExpiredAt}\n"
"\n"
"Even though this share has been revoked you still might have access through other shares and/or space memberships."
msgstr ""
"A sua partilha para {ShareFolder} expirou em {ExpiredAt}\n"
"\n"
"Ainda que esta partilha tenha sido revogada, poderá continuar a ter acesso através de outras partilhas e/ou subscrições de espaço."
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .MessageBody }}
#: pkg/email/templates.go:106
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"Please visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
"{ShareSharer} ({ShareSharerMail}) quer iniciar a partilha de recursos de colaboração consigo.\n"
"Por favor, visite as definições de federação e utilize os seguintes dados:\n"
"Token: {Token} \n"
"Podevor de Dominio: {ProviderDomain}"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{
#. .MessageBody }}
#: pkg/email/templates.go:89
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"To accept the invite, please visit the following URL:\n"
"{ShareLink}\n"
"\n"
"Alternatively, you can visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
"{ShareSharer} ({ShareSharerMail}) quer iniciar a partilha de recursos de colaboração consigo.\n"
"Para aceitar o convite, visite a seguinte URL: {ShareLink}\n"
"\n"
"Em alternativa, pode visitar as definições de federação e utilizar os seguintes dados:\n"
"Token: {Token} Provedor de Dominio: {ProviderDomain}"
#. ShareCreated email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:21
msgid "{ShareSharer} has shared \"{ShareFolder}\" with you."
msgstr "{ShareSharer} partilhou «{ShareFolder}» consigo."
#. ShareCreated email template, Subject field (resolves directly)
#: pkg/email/templates.go:17
msgid "{ShareSharer} shared '{ShareFolder}' with you"
msgstr "{ShareSharer} partilhou «{ShareFolder}» consigo"
#. SharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:48
msgid "{SpaceSharer} has invited you to join \"{SpaceName}\"."
msgstr "{SpaceSharer} convidou-o/a a juntar-se a «{SpaceName}»."
#. UnsharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:61
msgid ""
"{SpaceSharer} has removed you from \"{SpaceName}\".\n"
"\n"
"You might still have access through your other groups or direct membership."
msgstr ""
"{SpaceSharer} removeu-o/a de «{SpaceName}».\n"
"\n"
"Poderá ainda ter acesso através dos seus outros grupos ou subscrição direta."
#. SharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:44
msgid "{SpaceSharer} invited you to join {SpaceName}"
msgstr "{SpaceSharer} convidou-o/a a juntar-se a {SpaceName}"
#. UnsharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:57
msgid "{SpaceSharer} removed you from {SpaceName}"
msgstr "{SpaceSharer} removeu-o/a de {SpaceName}"
@@ -0,0 +1,179 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Savely Krasovsky, 2025
# Lulufox, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-05-06 00:01+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Lulufox, 2025\n"
"Language-Team: Russian (https://app.transifex.com/qsfera-eu/teams/204053/ru/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: ru\n"
"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n%100>=11 && n%100<=14)? 2 : 3);\n"
#. UnsharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:65
msgid "Click here to check it: {ShareLink}"
msgstr "Нажмите для проверки: {ShareLink}"
#. ShareCreated email template, resolves via {{ .CallToAction }}
#. SharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:23 pkg/email/templates.go:50
msgid "Click here to view it: {ShareLink}"
msgstr "Нажмите для просмотра: {ShareLink}"
#. ShareCreated email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:19
msgid "Hello {ShareGrantee}"
msgstr "Здравствуйте, {ShareGrantee}"
#. ShareExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:32
msgid "Hello {ShareGrantee},"
msgstr "Здравствуйте, {ShareGrantee},"
#. SharedSpace email template, resolves via {{ .Greeting }}
#. UnsharedSpace email template, resolves via {{ .Greeting }}
#. MembershipExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:46 pkg/email/templates.go:59
#: pkg/email/templates.go:74
msgid "Hello {SpaceGrantee},"
msgstr "Здравствуйте, {SpaceGrantee},"
#. Grouped email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:118
msgid "Hi {DisplayName},"
msgstr "Привет, {DisplayName},"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{ .Greeting
#. }}
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .Greeting }}
#: pkg/email/templates.go:87 pkg/email/templates.go:104
msgid "Hi,"
msgstr "Привет"
#. MembershipExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:72
msgid "Membership of '{SpaceName}' expired at {ExpiredAt}"
msgstr "Членство в '{SpaceName}' истекло {ExpiredAt}"
#. Grouped email template, Subject field (resolves directly)
#: pkg/email/templates.go:116
msgid "Report"
msgstr "Пожаловаться"
#. ScienceMeshInviteTokenGenerated email template, Subject field (resolves
#. directly)
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, Subject
#. field (resolves directly)
#: pkg/email/templates.go:85 pkg/email/templates.go:102
msgid "ScienceMesh: {InitiatorName} wants to collaborate with you"
msgstr "ScienceMesh: {InitiatorName} хочет сотрудничать с вами"
#. ShareExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:30
msgid "Share to '{ShareFolder}' expired at {ExpiredAt}"
msgstr "Совместный доступ к '{ShareFolder}' истек {ExpiredAt}"
#. MembershipExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:76
msgid ""
"Your membership of space {SpaceName} has expired at {ExpiredAt}\n"
"\n"
"Even though this membership has expired you still might have access through other shares and/or space memberships"
msgstr ""
"Ваше членство в пространстве {SpaceName} истекло {ExpiredAt}\n"
"\n"
"Не смотря на это, у вас может сохраняться доступ через ресурсы совместного доступа и/или членство в других пространствах."
#. ShareExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:34
msgid ""
"Your share to {ShareFolder} has expired at {ExpiredAt}\n"
"\n"
"Even though this share has been revoked you still might have access through other shares and/or space memberships."
msgstr ""
"Ваш совместный доступ к {ShareFolder} истек {ExpiredAt}\n"
"\n"
"Не смотря на это, у вас может сохраняться доступ через другие ресурсы совместного доступа и/или членство в пространствах."
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .MessageBody }}
#: pkg/email/templates.go:106
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"Please visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
"{ShareSharer} ({ShareSharerMail}) хочет начать совместно использовать ресурсы вместе с Вами.\n"
"Пожалуйста, перейдите в свои федеративные настройки и используйте следующие параметры:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{
#. .MessageBody }}
#: pkg/email/templates.go:89
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"To accept the invite, please visit the following URL:\n"
"{ShareLink}\n"
"\n"
"Alternatively, you can visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
"{ShareSharer} ({ShareSharerMail}) хочет начать совместно использовать ресурсы вместе с Вами.\n"
"Чтобы принять приглашение, перейдите по ссылкеL:\n"
"{ShareLink}\n"
"Или перейдите в свои федеративные настройки и используйте следующие параметры:\n"
"Token: {Token}\n"
"ProviderDomain: {ProviderDomain}"
#. ShareCreated email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:21
msgid "{ShareSharer} has shared \"{ShareFolder}\" with you."
msgstr "{ShareSharer} предоставил вам совместный доступ к \"{ShareFolder}\"."
#. ShareCreated email template, Subject field (resolves directly)
#: pkg/email/templates.go:17
msgid "{ShareSharer} shared '{ShareFolder}' with you"
msgstr "{ShareSharer} предоставил(-а) вам совместный доступ к '{ShareFolder}'"
#. SharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:48
msgid "{SpaceSharer} has invited you to join \"{SpaceName}\"."
msgstr "{SpaceSharer} пригласил(-а) Вас присоединиться к \"{SpaceName}\"."
#. UnsharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:61
msgid ""
"{SpaceSharer} has removed you from \"{SpaceName}\".\n"
"\n"
"You might still have access through your other groups or direct membership."
msgstr ""
"{SpaceSharer} удалил(-а) вас из \"{SpaceName}\".\n"
"\n"
"Вы всё ещё можете иметь доступ через ваши другие группы или прямое членство."
#. SharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:44
msgid "{SpaceSharer} invited you to join {SpaceName}"
msgstr "{SpaceSharer} пригласил(-а) Вас присоединиться к \"{SpaceName}\"."
#. UnsharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:57
msgid "{SpaceSharer} removed you from {SpaceName}"
msgstr "{SpaceSharer} исключил(-а) Вас из {SpaceName}"
@@ -0,0 +1,180 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Davis Kaza, 2025
# Daniel Nylander <po@danielnylander.se>, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-20 00:03+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Daniel Nylander <po@danielnylander.se>, 2025\n"
"Language-Team: Swedish (https://app.transifex.com/qsfera-eu/teams/204053/sv/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: sv\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. UnsharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:65
msgid "Click here to check it: {ShareLink}"
msgstr "Klicka här för att kontrollera det: {ShareLink}"
#. ShareCreated email template, resolves via {{ .CallToAction }}
#. SharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:23 pkg/email/templates.go:50
msgid "Click here to view it: {ShareLink}"
msgstr "Klicka här för att visa det: {ShareLink}"
#. ShareCreated email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:19
msgid "Hello {ShareGrantee}"
msgstr "Hej {ShareGrantee}"
#. ShareExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:32
msgid "Hello {ShareGrantee},"
msgstr "Hej {ShareGrantee},"
#. SharedSpace email template, resolves via {{ .Greeting }}
#. UnsharedSpace email template, resolves via {{ .Greeting }}
#. MembershipExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:46 pkg/email/templates.go:59
#: pkg/email/templates.go:74
msgid "Hello {SpaceGrantee},"
msgstr "Hej {SpaceGrantee},"
#. Grouped email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:118
msgid "Hi {DisplayName},"
msgstr "Hej {DisplayName},"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{ .Greeting
#. }}
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .Greeting }}
#: pkg/email/templates.go:87 pkg/email/templates.go:104
msgid "Hi,"
msgstr "Hej,"
#. MembershipExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:72
msgid "Membership of '{SpaceName}' expired at {ExpiredAt}"
msgstr "Medlemskapet i '{SpaceName}' löpte ut {ExpiredAt}"
#. Grouped email template, Subject field (resolves directly)
#: pkg/email/templates.go:116
msgid "Report"
msgstr "Rapport"
#. ScienceMeshInviteTokenGenerated email template, Subject field (resolves
#. directly)
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, Subject
#. field (resolves directly)
#: pkg/email/templates.go:85 pkg/email/templates.go:102
msgid "ScienceMesh: {InitiatorName} wants to collaborate with you"
msgstr "ScienceMesh: {InitiatorName} vill samarbeta med dig"
#. ShareExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:30
msgid "Share to '{ShareFolder}' expired at {ExpiredAt}"
msgstr "Delning till '{ShareFolder}' löpte ut {ExpiredAt}"
#. MembershipExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:76
msgid ""
"Your membership of space {SpaceName} has expired at {ExpiredAt}\n"
"\n"
"Even though this membership has expired you still might have access through other shares and/or space memberships"
msgstr ""
"Ditt medlemskap i arbetsytan {SpaceName} har löpt ut {ExpiredAt}\n"
"\n"
"Även om detta medlemskap har löpt ut kan du fortfarande ha tillgång via andra delningar och/eller medlemskap i arbetsytor"
#. ShareExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:34
msgid ""
"Your share to {ShareFolder} has expired at {ExpiredAt}\n"
"\n"
"Even though this share has been revoked you still might have access through other shares and/or space memberships."
msgstr ""
"Din delning till {ShareFolder} har löpt ut {ExpiredAt}\n"
"\n"
"Även om denna delning har löpt ut kan du fortfarande ha tillgång via andra delningar och/eller medlemskap i arbetsytor."
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .MessageBody }}
#: pkg/email/templates.go:106
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"Please visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
"{ShareSharer} ({ShareSharerMail}) vill börja dela samarbetsresurser med dig.\n"
"Kolla dina federationsinställningar och använd följande uppgifter:\n"
"Token: {Token}\n"
"ProviderDomain: {ProviderDomain}"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{
#. .MessageBody }}
#: pkg/email/templates.go:89
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"To accept the invite, please visit the following URL:\n"
"{ShareLink}\n"
"\n"
"Alternatively, you can visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
"{ShareSharer} ({ShareSharerMail}) vill börja dela samarbetsresurser med dig.\n"
"Besök följande URL för att acceptera inbjudan:\n"
"{ShareLink}\n"
"\n"
"Alternativt kan du kolla dina federationsinställningar och använda följande uppgifter:\n"
"Token: {Token}\n"
"ProviderDomain: {ProviderDomain}"
#. ShareCreated email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:21
msgid "{ShareSharer} has shared \"{ShareFolder}\" with you."
msgstr "{ShareSharer} har delat \"{ShareFolder}\" med dig."
#. ShareCreated email template, Subject field (resolves directly)
#: pkg/email/templates.go:17
msgid "{ShareSharer} shared '{ShareFolder}' with you"
msgstr "{ShareSharer} delade '{ShareFolder}' med dig"
#. SharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:48
msgid "{SpaceSharer} has invited you to join \"{SpaceName}\"."
msgstr "{SpaceSharer} har bjudit in dig till \"{SpaceName}\"."
#. UnsharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:61
msgid ""
"{SpaceSharer} has removed you from \"{SpaceName}\".\n"
"\n"
"You might still have access through your other groups or direct membership."
msgstr ""
"{SpaceSharer} har tagit bort dig från \"{SpaceName}\".\n"
"\n"
"Du kan fortfarande ha tillgång via dina andra grupper eller via direkt medlemskap."
#. SharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:44
msgid "{SpaceSharer} invited you to join {SpaceName}"
msgstr "{SpaceSharer} bjöd in dig att gå med i {SpaceName}"
#. UnsharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:57
msgid "{SpaceSharer} removed you from {SpaceName}"
msgstr "{SpaceSharer} tog bort dig från {SpaceName}"
@@ -0,0 +1,179 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# YQS Yang, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-22 00:03+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: YQS Yang, 2025\n"
"Language-Team: Chinese (https://app.transifex.com/qsfera-eu/teams/204053/zh/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: zh\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#. UnsharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:65
msgid "Click here to check it: {ShareLink}"
msgstr "点击此处查看:{ShareLink}"
#. ShareCreated email template, resolves via {{ .CallToAction }}
#. SharedSpace email template, resolves via {{ .CallToAction }}
#: pkg/email/templates.go:23 pkg/email/templates.go:50
msgid "Click here to view it: {ShareLink}"
msgstr "点击此处查看:{ShareLink}"
#. ShareCreated email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:19
msgid "Hello {ShareGrantee}"
msgstr "您好,{ShareGrantee}"
#. ShareExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:32
msgid "Hello {ShareGrantee},"
msgstr "您好,{ShareGrantee}"
#. SharedSpace email template, resolves via {{ .Greeting }}
#. UnsharedSpace email template, resolves via {{ .Greeting }}
#. MembershipExpired email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:46 pkg/email/templates.go:59
#: pkg/email/templates.go:74
msgid "Hello {SpaceGrantee},"
msgstr "您好,{SpaceGrantee}"
#. Grouped email template, resolves via {{ .Greeting }}
#: pkg/email/templates.go:118
msgid "Hi {DisplayName},"
msgstr "您好,{DisplayName}"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{ .Greeting
#. }}
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .Greeting }}
#: pkg/email/templates.go:87 pkg/email/templates.go:104
msgid "Hi,"
msgstr "您好:"
#. MembershipExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:72
msgid "Membership of '{SpaceName}' expired at {ExpiredAt}"
msgstr "您在空间“{SpaceName}”的成员资格已于{ExpiredAt}过期"
#. Grouped email template, Subject field (resolves directly)
#: pkg/email/templates.go:116
msgid "Report"
msgstr "报告"
#. ScienceMeshInviteTokenGenerated email template, Subject field (resolves
#. directly)
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, Subject
#. field (resolves directly)
#: pkg/email/templates.go:85 pkg/email/templates.go:102
msgid "ScienceMesh: {InitiatorName} wants to collaborate with you"
msgstr "ScienceMesh{InitiatorName}希望与您协作"
#. ShareExpired email template, Subject field (resolves directly)
#: pkg/email/templates.go:30
msgid "Share to '{ShareFolder}' expired at {ExpiredAt}"
msgstr "共享文件夹“{ShareFolder}”的访问权限已于{ExpiredAt}过期"
#. MembershipExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:76
msgid ""
"Your membership of space {SpaceName} has expired at {ExpiredAt}\n"
"\n"
"Even though this membership has expired you still might have access through other shares and/or space memberships"
msgstr ""
"您在空间{SpaceName}的成员资格已于{ExpiredAt}过期\n"
"\n"
"即使此成员资格已过期,您仍可能通过其他共享和/或空间成员资格获得访问权限"
#. ShareExpired email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:34
msgid ""
"Your share to {ShareFolder} has expired at {ExpiredAt}\n"
"\n"
"Even though this share has been revoked you still might have access through other shares and/or space memberships."
msgstr ""
"您对{ShareFolder}的共享访问权限已于{ExpiredAt}过期\n"
"\n"
"即使此共享已被撤销,您仍可能通过其他共享和/或空间成员资格获得访问权限"
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
#. via {{ .MessageBody }}
#: pkg/email/templates.go:106
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"Please visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
"{ShareSharer}{ShareSharerMail})希望与您共享协作资源\n"
"请访问您的联邦设置并使用以下详细信息:\n"
" 令牌:{Token}\n"
" 提供商域:{ProviderDomain}"
#. ScienceMeshInviteTokenGenerated email template, resolves via {{
#. .MessageBody }}
#: pkg/email/templates.go:89
msgid ""
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
"To accept the invite, please visit the following URL:\n"
"{ShareLink}\n"
"\n"
"Alternatively, you can visit your federation settings and use the following details:\n"
" Token: {Token}\n"
" ProviderDomain: {ProviderDomain}"
msgstr ""
"{ShareSharer}{ShareSharerMail})希望与您共享协作资源\n"
"要接受邀请,请访问以下URL\n"
"{ShareLink}\n"
"\n"
"或者,您可以访问您的联邦设置并使用以下详细信息:\n"
" 令牌:{Token}\n"
" 提供商域:{ProviderDomain}"
#. ShareCreated email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:21
msgid "{ShareSharer} has shared \"{ShareFolder}\" with you."
msgstr "{ShareSharer}已与您共享了“{ShareFolder}”"
#. ShareCreated email template, Subject field (resolves directly)
#: pkg/email/templates.go:17
msgid "{ShareSharer} shared '{ShareFolder}' with you"
msgstr "{ShareSharer}与您共享了“{ShareFolder}”"
#. SharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:48
msgid "{SpaceSharer} has invited you to join \"{SpaceName}\"."
msgstr "{SpaceSharer}已邀请您加入“{SpaceName}”"
#. UnsharedSpace email template, resolves via {{ .MessageBody }}
#: pkg/email/templates.go:61
msgid ""
"{SpaceSharer} has removed you from \"{SpaceName}\".\n"
"\n"
"You might still have access through your other groups or direct membership."
msgstr ""
"{SpaceSharer}已将您从“{SpaceName}”中移除\n"
"\n"
"您可能仍可通过其他群组或直接成员资格获得访问权限"
#. SharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:44
msgid "{SpaceSharer} invited you to join {SpaceName}"
msgstr "{SpaceSharer}邀请您加入{SpaceName}"
#. UnsharedSpace email template, Subject field (resolves directly)
#: pkg/email/templates.go:57
msgid "{SpaceSharer} removed you from {SpaceName}"
msgstr "{SpaceSharer}已将您从{SpaceName}中移除"
@@ -0,0 +1,162 @@
package email
import "github.com/qsfera/server/pkg/l10n"
const (
_textTemplate = "templates/text/email.text.tmpl"
_htmlTemplate = "templates/html/email.html.tmpl"
)
// the available templates
var (
// Shares
ShareCreated = MessageTemplate{
textTemplate: _textTemplate,
htmlTemplate: _htmlTemplate,
// ShareCreated email template, Subject field (resolves directly)
Subject: l10n.Template(`{ShareSharer} shared '{ShareFolder}' with you`),
// ShareCreated email template, resolves via {{ .Greeting }}
Greeting: l10n.Template(`Hello {ShareGrantee}`),
// ShareCreated email template, resolves via {{ .MessageBody }}
MessageBody: l10n.Template(`{ShareSharer} has shared "{ShareFolder}" with you.`),
// ShareCreated email template, resolves via {{ .CallToAction }}
CallToAction: l10n.Template(`Click here to view it: {ShareLink}`),
}
ShareExpired = MessageTemplate{
textTemplate: _textTemplate,
htmlTemplate: _htmlTemplate,
// ShareExpired email template, Subject field (resolves directly)
Subject: l10n.Template(`Share to '{ShareFolder}' expired at {ExpiredAt}`),
// ShareExpired email template, resolves via {{ .Greeting }}
Greeting: l10n.Template(`Hello {ShareGrantee},`),
// ShareExpired email template, resolves via {{ .MessageBody }}
MessageBody: l10n.Template(`Your share to {ShareFolder} has expired at {ExpiredAt}
Even though this share has been revoked you still might have access through other shares and/or space memberships.`),
}
// Spaces templates
SharedSpace = MessageTemplate{
textTemplate: _textTemplate,
htmlTemplate: _htmlTemplate,
// SharedSpace email template, Subject field (resolves directly)
Subject: l10n.Template("{SpaceSharer} invited you to join {SpaceName}"),
// SharedSpace email template, resolves via {{ .Greeting }}
Greeting: l10n.Template(`Hello {SpaceGrantee},`),
// SharedSpace email template, resolves via {{ .MessageBody }}
MessageBody: l10n.Template(`{SpaceSharer} has invited you to join "{SpaceName}".`),
// SharedSpace email template, resolves via {{ .CallToAction }}
CallToAction: l10n.Template(`Click here to view it: {ShareLink}`),
}
UnsharedSpace = MessageTemplate{
textTemplate: _textTemplate,
htmlTemplate: _htmlTemplate,
// UnsharedSpace email template, Subject field (resolves directly)
Subject: l10n.Template(`{SpaceSharer} removed you from {SpaceName}`),
// UnsharedSpace email template, resolves via {{ .Greeting }}
Greeting: l10n.Template(`Hello {SpaceGrantee},`),
// UnsharedSpace email template, resolves via {{ .MessageBody }}
MessageBody: l10n.Template(`{SpaceSharer} has removed you from "{SpaceName}".
You might still have access through your other groups or direct membership.`),
// UnsharedSpace email template, resolves via {{ .CallToAction }}
CallToAction: l10n.Template(`Click here to check it: {ShareLink}`),
}
MembershipExpired = MessageTemplate{
textTemplate: _textTemplate,
htmlTemplate: _htmlTemplate,
// MembershipExpired email template, Subject field (resolves directly)
Subject: l10n.Template(`Membership of '{SpaceName}' expired at {ExpiredAt}`),
// MembershipExpired email template, resolves via {{ .Greeting }}
Greeting: l10n.Template(`Hello {SpaceGrantee},`),
// MembershipExpired email template, resolves via {{ .MessageBody }}
MessageBody: l10n.Template(`Your membership of space {SpaceName} has expired at {ExpiredAt}
Even though this membership has expired you still might have access through other shares and/or space memberships`),
}
ScienceMeshInviteTokenGenerated = MessageTemplate{
textTemplate: _textTemplate,
htmlTemplate: _htmlTemplate,
// ScienceMeshInviteTokenGenerated email template, Subject field (resolves directly)
Subject: l10n.Template(`ScienceMesh: {InitiatorName} wants to collaborate with you`),
// ScienceMeshInviteTokenGenerated email template, resolves via {{ .Greeting }}
Greeting: l10n.Template(`Hi,`),
// ScienceMeshInviteTokenGenerated email template, resolves via {{ .MessageBody }}
MessageBody: l10n.Template(`{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.
To accept the invite, please visit the following URL:
{ShareLink}
Alternatively, you can visit your federation settings and use the following details:
Token: {Token}
ProviderDomain: {ProviderDomain}`),
}
ScienceMeshInviteTokenGeneratedWithoutShareLink = MessageTemplate{
textTemplate: _textTemplate,
htmlTemplate: _htmlTemplate,
// ScienceMeshInviteTokenGeneratedWithoutShareLink email template, Subject field (resolves directly)
Subject: l10n.Template(`ScienceMesh: {InitiatorName} wants to collaborate with you`),
// ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves via {{ .Greeting }}
Greeting: l10n.Template(`Hi,`),
// ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves via {{ .MessageBody }}
MessageBody: l10n.Template(`{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.
Please visit your federation settings and use the following details:
Token: {Token}
ProviderDomain: {ProviderDomain}`),
}
Grouped = GroupedMessageTemplate{
textTemplate: _textTemplate,
htmlTemplate: _htmlTemplate,
// Grouped email template, Subject field (resolves directly)
Subject: l10n.Template(`Report`), // TODO find meaningful subject
// Grouped email template, resolves via {{ .Greeting }}
Greeting: l10n.Template(`Hi {DisplayName},`),
MessageBody: "", // is generated using the GroupedTemplates
}
)
// holds the information to turn the raw template into a parseable go template
var _placeholders = map[string]string{
"{ShareSharer}": "{{ .ShareSharer }}",
"{ShareFolder}": "{{ .ShareFolder }}",
"{ShareGrantee}": "{{ .ShareGrantee }}",
"{ShareLink}": "{{ .ShareLink }}",
"{SpaceName}": "{{ .SpaceName }}",
"{SpaceGrantee}": "{{ .SpaceGrantee }}",
"{SpaceSharer}": "{{ .SpaceSharer }}",
"{ExpiredAt}": "{{ .ExpiredAt }}",
"{ShareSharerMail}": "{{ .ShareSharerMail }}",
"{ProviderDomain}": "{{ .ProviderDomain }}",
"{Token}": "{{ .Token }}",
"{DisplayName}": "{{ .DisplayName }}",
}
// MessageTemplate is the data structure for the email
type MessageTemplate struct {
// textTemplate represent the path to text plain .tmpl file
textTemplate string
// htmlTemplate represent the path to html .tmpl file
htmlTemplate string
// The fields below represent the placeholders for the translatable templates
Subject string
Greeting string
MessageBody string
CallToAction string
}
// GroupedMessageTemplate is the data structure for the email
type GroupedMessageTemplate struct {
// textTemplate represent the path to text plain .tmpl file
textTemplate string
// htmlTemplate represent the path to html .tmpl file
htmlTemplate string
// The fields below represent the placeholders for the translatable templates
Subject string
Greeting string
MessageBody string
}
@@ -0,0 +1,41 @@
<!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;">
{{ .Greeting }}
<br><br>
{{ .MessageBody }}
{{if ne .CallToAction "" }}<br><br>
{{ .CallToAction }}{{end}}
</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>
КуСфера - a safe home for all your data<br>
<a href="https://qsfera.eu">https://qsfera.eu</a>
</footer>
</td>
</tr>
<tr>
<td colspan="2">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -0,0 +1,10 @@
{{ .Greeting }}
{{ .MessageBody }}
{{if ne .CallToAction "" }}
{{ .CallToAction }}
{{end}}
---
КуСфера - a safe home for all your data
https://qsfera.eu
@@ -0,0 +1,50 @@
package debug
import (
"context"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/notifications/pkg/config"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Logger log.Logger
Context context.Context
Config *config.Config
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Logger provides a function to set the logger option.
func Logger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}
// Context provides a function to set the context option.
func Context(val context.Context) Option {
return func(o *Options) {
o.Context = val
}
}
// Config provides a function to set the config option.
func Config(val *config.Config) Option {
return func(o *Options) {
o.Config = val
}
}
@@ -0,0 +1,32 @@
package debug
import (
"net/http"
"strconv"
"github.com/qsfera/server/pkg/checks"
"github.com/qsfera/server/pkg/handlers"
"github.com/qsfera/server/pkg/service/debug"
"github.com/qsfera/server/pkg/version"
)
// Server initializes the debug service and server.
func Server(opts ...Option) (*http.Server, error) {
options := newOptions(opts...)
readyHandlerConfiguration := handlers.NewCheckHandlerConfiguration().
WithLogger(options.Logger).
WithCheck("nats reachability", checks.NewNatsCheck(options.Config.Notifications.Events.Endpoint)).
WithCheck("smtp-check", checks.NewTCPCheck(options.Config.Notifications.SMTP.Host+":"+strconv.Itoa(options.Config.Notifications.SMTP.Port)))
return debug.NewService(
debug.Logger(options.Logger),
debug.Name(options.Config.Service.Name),
debug.Version(version.GetString()),
debug.Address(options.Config.Debug.Addr),
debug.Token(options.Config.Debug.Token),
debug.Pprof(options.Config.Debug.Pprof),
debug.Zpages(options.Config.Debug.Zpages),
debug.Ready(handlers.NewCheckHandler(readyHandlerConfiguration)),
), nil
}
@@ -0,0 +1,63 @@
package service
import (
"context"
"errors"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/middleware"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
micrometadata "go-micro.dev/v4/metadata"
)
type notificationFilter struct {
log log.Logger
valueClient settingssvc.ValueService
}
func newNotificationFilter(l log.Logger, vc settingssvc.ValueService) *notificationFilter {
return &notificationFilter{log: l, valueClient: vc}
}
// execute removes users who have disabled mail notifications for the event
func (nf notificationFilter) execute(ctx context.Context, users []*user.User, settingId string) []*user.User {
var filteredUsers []*user.User
for _, u := range users {
userId := u.GetId().GetOpaqueId()
enabled, err := getSetting(ctx, nf.valueClient, userId, settingId)
if err != nil {
nf.log.Error().Err(err).Str("userId", userId).Str("settingId", settingId).Msg("cannot get user event setting")
filteredUsers = append(filteredUsers, u)
continue
}
if enabled {
filteredUsers = append(filteredUsers, u)
}
}
return filteredUsers
}
func getSetting(ctx context.Context, vc settingssvc.ValueService, userId string, settingId string) (bool, error) {
resp, err := vc.GetValueByUniqueIdentifiers(
micrometadata.Set(ctx, middleware.AccountID, userId),
&settingssvc.GetValueByUniqueIdentifiersRequest{
AccountUuid: userId,
SettingId: settingId,
},
)
if err != nil {
return false, err
}
val := resp.GetValue().GetValue().GetCollectionValue().GetValues()
for _, option := range val {
if option.GetKey() == "mail" {
return option.GetBoolValue(), nil
}
}
return false, errors.New("no setting found")
}
@@ -0,0 +1,91 @@
package service
import (
"context"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/qsfera/server/pkg/log"
settingsmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/settings/v0"
settings "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
settingsmocks "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0/mocks"
"github.com/pkg/errors"
"github.com/stretchr/testify/mock"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("NotificationFilter", func() {
var (
testLogger = log.NewLogger()
vs = &settingsmocks.ValueService{}
)
setupMockValueService := func(mail bool) *settingsmocks.ValueService {
m := &settingsmocks.ValueService{}
m.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(&settings.GetValueResponse{
Value: &settingsmsg.ValueWithIdentifier{
Value: &settingsmsg.Value{
Value: &settingsmsg.Value_CollectionValue{
CollectionValue: &settingsmsg.CollectionValue{
Values: []*settingsmsg.CollectionOption{
{
Key: "mail",
Option: &settingsmsg.CollectionOption_BoolValue{BoolValue: mail},
},
},
},
},
},
},
}, nil)
return m
}
Describe("execute", func() {
It("handles connection errors", func() {
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(nil, errors.New("no connection to ValueService"))
ulf := notificationFilter{
log: testLogger,
valueClient: vs,
}
Expect(ulf.execute(context.TODO(), []*user.User{{Id: &user.UserId{OpaqueId: "foo"}}}, "bar")).To(ConsistOf(&user.User{Id: &user.UserId{OpaqueId: "foo"}}))
})
It("handles no setting in ValueService response", func() {
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(&settings.GetValueResponse{}, nil)
ulf := notificationFilter{
log: testLogger,
valueClient: vs,
}
Expect(ulf.execute(context.TODO(), []*user.User{{Id: &user.UserId{OpaqueId: "foo"}}}, "bar")).To(ConsistOf(&user.User{Id: &user.UserId{OpaqueId: "foo"}}))
})
It("handles nil responses", func() {
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(nil, nil)
ulf := notificationFilter{
log: testLogger,
valueClient: vs,
}
Expect(ulf.execute(context.TODO(), []*user.User{{Id: &user.UserId{OpaqueId: "foo"}}}, "bar")).To(ConsistOf(&user.User{Id: &user.UserId{OpaqueId: "foo"}}))
})
It("return users when events are enabled", func() {
vs = setupMockValueService(true)
ulf := notificationFilter{
log: testLogger,
valueClient: vs,
}
Expect(ulf.execute(context.TODO(), []*user.User{{Id: &user.UserId{OpaqueId: "foo"}}}, "bar")).To(ConsistOf(&user.User{Id: &user.UserId{OpaqueId: "foo"}}))
})
It("return no users when events are disabled", func() {
vs = setupMockValueService(false)
ulf := notificationFilter{
log: testLogger,
valueClient: vs,
}
Expect(ulf.execute(context.TODO(), []*user.User{{Id: &user.UserId{OpaqueId: "foo"}}}, "bar")).To(BeEmpty())
})
})
})
@@ -0,0 +1,162 @@
package service
import (
"context"
"github.com/qsfera/server/pkg/l10n"
ehmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/eventhistory/v0"
"github.com/qsfera/server/services/notifications/pkg/channels"
"github.com/qsfera/server/services/notifications/pkg/email"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/rs/zerolog"
)
func (s eventsNotifier) sendGroupedEmailsJob(sendEmailsEvent events.SendEmailsEvent, eventId string) {
logger := s.logger.With().
Str("event", "SendEmailsEvent").
Str("eventId", eventId).
Logger()
if sendEmailsEvent.Interval != _intervalDaily && sendEmailsEvent.Interval != _intervalWeekly {
logger.Error().Str("interval", sendEmailsEvent.Interval).Msg("unsupported email sending interval")
return
}
keys, err := s.userEventStore.listKeys(sendEmailsEvent.Interval)
if err != nil {
logger.Error().Err(err).Msg("could not get list of keys")
return
}
ctx := context.Background()
jobs := make(chan string, 10)
go func() {
for _, key := range keys {
jobs <- key
}
close(jobs)
}()
for job := range jobs {
go s.createGroupedMail(ctx, logger, job)
}
}
func (s eventsNotifier) createGroupedMail(ctx context.Context, logger zerolog.Logger, key string) {
userEvents, err := s.userEventStore.pop(ctx, key)
if err != nil {
logger.Error().Err(err).Str("key", key).Msg("could not pop user events")
return
}
var mts []email.MessageTemplate
var mtsVars []map[string]string
locale := l10n.MustGetUserLocale(ctx, userEvents.User.GetId().GetOpaqueId(), "", s.valueService)
for _, e := range userEvents.Events {
switch te := s.unwrapEvent(logger, e).(type) {
case events.SpaceShared:
logger := logger.With().
Str("event", "SpaceShared").
Str("eventId", te.ID.OpaqueId).
Logger()
executant, spaceName, shareLink, _, err := s.prepareSpaceShared(logger, te)
if err != nil {
logger.Error().Err(err).Msg("could not prepare vars for grouped email")
continue
}
mts = append(mts, email.SharedSpace)
mtsVars = append(mtsVars, map[string]string{
"SpaceSharer": executant.GetDisplayName(),
"SpaceName": spaceName,
"ShareLink": shareLink,
})
case events.SpaceUnshared:
logger := logger.With().
Str("event", "SpaceUnshared").
Str("eventId", te.ID.OpaqueId).
Logger()
executant, spaceName, shareLink, _, err := s.prepareSpaceUnshared(logger, te)
if err != nil {
logger.Error().Err(err).Msg("could not prepare vars for grouped email")
continue
}
mts = append(mts, email.UnsharedSpace)
mtsVars = append(mtsVars, map[string]string{
"SpaceSharer": executant.GetDisplayName(),
"SpaceName": spaceName,
"ShareLink": shareLink,
})
case events.SpaceMembershipExpired:
mts = append(mts, email.MembershipExpired)
mtsVars = append(mtsVars, map[string]string{
"SpaceName": te.SpaceName,
"ExpiredAt": te.ExpiredAt.Format("2006-01-02 15:04:05"),
})
case events.ShareCreated:
logger := logger.With().
Str("event", "ShareCreated").
Str("eventId", te.ItemID.OpaqueId).
Logger()
owner, shareFolder, shareLink, _, err := s.prepareShareCreated(logger, te)
if err != nil {
logger.Error().Err(err).Msg("could not prepare vars for grouped email")
continue
}
mts = append(mts, email.ShareCreated)
mtsVars = append(mtsVars, map[string]string{
"ShareSharer": owner.GetDisplayName(),
"ShareFolder": shareFolder,
"ShareLink": shareLink,
})
case events.ShareExpired:
logger := logger.With().
Str("event", "ShareCreated").
Str("eventId", te.ItemID.OpaqueId).
Logger()
shareFolder, _, err := s.prepareShareExpired(logger, te)
if err != nil {
logger.Error().Err(err).Msg("could not prepare vars for grouped email")
continue
}
mts = append(mts, email.ShareExpired)
mtsVars = append(mtsVars, map[string]string{
"ShareFolder": shareFolder,
"ExpiredAt": te.ExpiredAt.Format("2006-01-02 15:04:05"),
})
}
}
rendered, err := email.RenderGroupedEmailTemplate(email.Grouped, map[string]string{
"DisplayName": userEvents.User.GetDisplayName(),
}, locale, s.defaultLanguage, s.emailTemplatePath, s.translationPath, mts, mtsVars)
if err != nil {
logger.Error().Err(err).Msg("could not render template")
return
}
rendered.Sender = s.defaultEmailSender
rendered.Recipient = []string{userEvents.User.GetMail()}
s.send(ctx, []*channels.Message{rendered})
}
func (s eventsNotifier) unwrapEvent(logger zerolog.Logger, e *ehmsg.Event) any {
etype, ok := s.registeredEvents[e.GetType()]
if !ok {
logger.Error().Str("eventId", e.GetId()).Str("eventType", e.GetType()).Msg("event not registered")
return nil
}
ue, err := etype.Unmarshal(e.GetEvent())
if err != nil {
logger.Error().Str("eventId", e.GetId()).Str("eventType", e.GetType()).Msg("failed to umarshal event")
return nil
}
return ue
}
@@ -0,0 +1,26 @@
package service_test
import (
"testing"
"github.com/qsfera/server/pkg/registry"
mRegistry "go-micro.dev/v4/registry"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func init() {
r := registry.GetRegistry(registry.Inmemory())
service := registry.BuildGRPCService("qsfera.api.gateway", "", "", "")
service.Nodes = []*mRegistry.Node{{
Address: "any",
}}
_ = r.Register(service)
}
func TestNotifications(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Notification Suite")
}
@@ -0,0 +1,117 @@
package service
import (
"context"
"encoding/json"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/qsfera/server/pkg/log"
v0 "github.com/qsfera/server/protogen/gen/qsfera/messages/eventhistory/v0"
ehsvc "github.com/qsfera/server/protogen/gen/qsfera/services/eventhistory/v0"
"github.com/pkg/errors"
"go-micro.dev/v4/store"
)
type userEventStore struct {
log log.Logger
store store.Store
historyClient ehsvc.EventHistoryService
}
type userEventIds struct {
User *user.User `json:"user"`
EventIds []string `json:"event_ids"`
}
type userEvents struct {
User *user.User
Events []*v0.Event
}
const (
_intervalDaily = "daily"
_intervalWeekly = "weekly"
)
func newUserEventStore(l log.Logger, s store.Store, hc ehsvc.EventHistoryService) *userEventStore {
return &userEventStore{log: l, store: s, historyClient: hc}
}
func (s *userEventStore) persist(interval string, eventId string, users []*user.User) []*user.User {
var errorUsers []*user.User
for _, u := range users {
key := interval + u.Id.OpaqueId
// Note: This is not thread safe and can result in missing events
records, err := s.store.Read(key)
if err != nil && err != store.ErrNotFound {
s.log.Error().Err(err).Str("eventId", eventId).Str("userId", u.Id.OpaqueId).Msg("cannot read record")
errorUsers = append(errorUsers, u)
continue
}
var record userEventIds
if len(records) == 0 {
record = userEventIds{}
} else {
if err = json.Unmarshal(records[0].Value, &record); err != nil {
s.log.Warn().Err(err).Str("eventId", eventId).Str("userId", u.Id.OpaqueId).Msg("cannot unmarshal json")
errorUsers = append(errorUsers, u)
continue
}
}
record.User = u
record.EventIds = append(record.EventIds, eventId)
b, err := json.Marshal(record)
if err != nil {
s.log.Warn().Err(err).Str("eventId", eventId).Str("userId", u.Id.OpaqueId).Msg("cannot marshal record")
errorUsers = append(errorUsers, u)
continue
}
err = s.store.Write(&store.Record{
Key: key,
Value: b,
})
if err != nil {
s.log.Error().Err(err).Str("eventId", eventId).Str("userId", u.Id.OpaqueId).Msg("cannot write record")
errorUsers = append(errorUsers, u)
continue
}
}
return errorUsers
}
func (s *userEventStore) listKeys(interval string) ([]string, error) {
return s.store.List(store.ListPrefix(interval))
}
func (s *userEventStore) pop(ctx context.Context, key string) (*userEvents, error) {
records, err := s.store.Read(key)
if err != nil && err != store.ErrNotFound {
return nil, errors.New("cannot get records")
}
if len(records) == 0 {
return nil, errors.New("no records found")
}
var record userEventIds
err = json.Unmarshal(records[0].Value, &record)
if err != nil {
s.log.Warn().Err(err).Str("key", key).Msg("cannot unmarshal json")
return nil, err
}
res, err := s.historyClient.GetEvents(ctx, &ehsvc.GetEventsRequest{Ids: record.EventIds})
if err != nil {
s.log.Error().Err(err).Strs("eventIds", record.EventIds).Msg("cannot get events")
return nil, err
}
err = s.store.Delete(key)
if err != nil {
s.log.Error().Err(err).Strs("eventIds", record.EventIds).Msg("cannot delete records")
return nil, err
}
return &userEvents{
User: record.User,
Events: res.GetEvents(),
}, nil
}
@@ -0,0 +1,82 @@
package service
import (
"context"
"github.com/qsfera/server/services/notifications/pkg/channels"
"github.com/qsfera/server/services/notifications/pkg/email"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/utils"
)
func (s eventsNotifier) handleScienceMeshInviteTokenGenerated(e events.ScienceMeshInviteTokenGenerated) {
logger := s.logger.With().
Str("event", "ScienceMeshInviteTokenGenerated").
Logger()
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 impersonate service user")
return
}
owner, err := utils.GetUserNoGroups(ctx, e.Sharer, gatewayClient)
if err != nil {
logger.Error().Err(err).Msg("unable to get user")
return
}
msgENV := map[string]string{
"ShareSharer": owner.GetDisplayName(),
"ShareSharerMail": owner.GetMail(),
"ShareLink": e.InviteLink,
"Token": e.Token,
"ProviderDomain": owner.GetId().GetIdp(),
"RecipientMail": e.RecipientMail,
}
// validate the message, we only need recipient mail at the moment,
// event that is optional when the event got triggered...
// this means if we get a validation error, we can't send the message and skip it
{
validationEnv := make(map[string]any, len(msgENV))
for k, v := range msgENV {
validationEnv[k] = v
}
if errs := validate.ValidateMap(validationEnv,
map[string]any{
"RecipientMail": "required,email", // only recipient mail is required to send the message
}); len(errs) > 0 {
return // no mail, no message
}
}
emailTpl := email.ScienceMeshInviteTokenGenerated
if e.InviteLink == "" {
emailTpl = email.ScienceMeshInviteTokenGeneratedWithoutShareLink
}
msg, err := email.RenderEmailTemplate(
emailTpl,
s.defaultLanguage, // fixMe: the recipient is unknown, should it be the defaultLocale?,
s.defaultLanguage, // fixMe: the defaultLocale is not set by default, shouldn't it be?,
s.emailTemplatePath,
s.translationPath,
msgENV,
)
if err != nil {
logger.Error().Err(err).Msg("building the message has failed")
return
}
msg.Sender = owner.GetDisplayName()
msg.Recipient = []string{e.RecipientMail}
s.send(ctx, []*channels.Message{msg})
}
@@ -0,0 +1,314 @@
package service
import (
"context"
"errors"
"fmt"
"net/url"
"path"
"strings"
"sync"
"sync/atomic"
ehsvc "github.com/qsfera/server/protogen/gen/qsfera/services/eventhistory/v0"
"go-micro.dev/v4/store"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
group "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/go-playground/validator/v10"
"go-micro.dev/v4/metadata"
"google.golang.org/protobuf/types/known/fieldmaskpb"
"github.com/qsfera/server/pkg/l10n"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/middleware"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/notifications/pkg/channels"
"github.com/qsfera/server/services/notifications/pkg/email"
"github.com/qsfera/server/services/settings/pkg/store/defaults"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
)
// validate is the package level validator instance
var validate *validator.Validate
func init() {
validate = validator.New()
}
// Service should be named `Runner`
type Service interface {
Run() error
Close()
}
// NewEventsNotifier provides a new eventsNotifier
func NewEventsNotifier(
events <-chan events.Event,
channel channels.Channel,
logger log.Logger,
gatewaySelector pool.Selectable[gateway.GatewayAPIClient],
valueService settingssvc.ValueService,
serviceAccountID, serviceAccountSecret, emailTemplatePath, defaultLanguage, qsferaURL, translationPath, emailSender string,
store store.Store,
historyClient ehsvc.EventHistoryService,
registeredEvents map[string]events.Unmarshaller) Service {
return eventsNotifier{
logger: logger,
channel: channel,
events: events,
gatewaySelector: gatewaySelector,
valueService: valueService,
serviceAccountID: serviceAccountID,
serviceAccountSecret: serviceAccountSecret,
emailTemplatePath: emailTemplatePath,
defaultLanguage: defaultLanguage,
defaultEmailSender: emailSender,
qsferaURL: qsferaURL,
translationPath: translationPath,
filter: newNotificationFilter(logger, valueService),
splitter: newIntervalSplitter(logger, valueService),
userEventStore: newUserEventStore(logger, store, historyClient),
registeredEvents: registeredEvents,
stopCh: make(chan struct{}, 1),
stopped: new(atomic.Bool),
}
}
type eventsNotifier struct {
logger log.Logger
channel channels.Channel
events <-chan events.Event
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
valueService settingssvc.ValueService
emailTemplatePath string
translationPath string
defaultLanguage string
defaultEmailSender string
qsferaURL string
serviceAccountID string
serviceAccountSecret string
filter *notificationFilter
splitter *intervalSplitter
userEventStore *userEventStore
registeredEvents map[string]events.Unmarshaller
stopCh chan struct{}
stopped *atomic.Bool
}
func (s eventsNotifier) Run() error {
var wg sync.WaitGroup
s.logger.Debug().
Msg("eventsNotifier started")
EventLoop:
for {
select {
case evt, ok := <-s.events:
if !ok {
break EventLoop
}
// TODO: needs to be replaced with a worker pool
wg.Go(func() {
switch e := evt.Event.(type) {
case events.SpaceShared:
s.handleSpaceShared(e, evt.ID)
case events.SpaceUnshared:
s.handleSpaceUnshared(e, evt.ID)
case events.SpaceMembershipExpired:
s.handleSpaceMembershipExpired(e, evt.ID)
case events.ShareCreated:
s.handleShareCreated(e, evt.ID)
case events.ShareExpired:
s.handleShareExpired(e, evt.ID)
case events.ScienceMeshInviteTokenGenerated:
s.handleScienceMeshInviteTokenGenerated(e)
case events.SendEmailsEvent:
s.sendGroupedEmailsJob(e, evt.ID)
}
})
if s.stopped.Load() {
break EventLoop
}
case <-s.stopCh:
s.logger.Debug().
Msg("eventsNotifier stopped")
break EventLoop
}
}
// wait until all the goroutines processing events have finished
wg.Wait()
return nil
}
func (s eventsNotifier) Close() {
if s.stopped.CompareAndSwap(false, true) {
close(s.stopCh)
}
}
func (s eventsNotifier) render(ctx context.Context, template email.MessageTemplate,
granteeFieldName string, fields map[string]string, granteeList []*user.User, sender string) ([]*channels.Message, error) {
// Render the Email Template for each user
messageList := make([]*channels.Message, len(granteeList))
for i, usr := range granteeList {
locale := l10n.MustGetUserLocale(ctx, usr.GetId().GetOpaqueId(), "", s.valueService)
fields[granteeFieldName] = usr.GetDisplayName()
rendered, err := email.RenderEmailTemplate(template, locale, s.defaultLanguage, s.emailTemplatePath, s.translationPath, fields)
if err != nil {
return nil, err
}
rendered.Sender = sender
rendered.Recipient = []string{usr.GetMail()}
messageList[i] = rendered
}
return messageList, nil
}
func (s eventsNotifier) send(ctx context.Context, emails []*channels.Message) {
for _, r := range emails {
err := s.channel.SendMessage(ctx, r)
if err != nil {
s.logger.Error().Err(err).Str("event", "SendEmail").Msg("failed to send a message")
}
}
}
func (s eventsNotifier) ensureGranteeList(ctx context.Context, executant, u *user.UserId, g *group.GroupId) []*user.User {
granteeList, err := s.getGranteeList(ctx, executant, u, g)
if err != nil {
s.logger.Error().Err(err).Str("event", "ensureGranteeList").Msg("Could not get grantee list")
return nil
} else if len(granteeList) == 0 {
return nil
}
return granteeList
}
func (s eventsNotifier) getGranteeList(ctx context.Context, executant, u *user.UserId, g *group.GroupId) ([]*user.User, error) {
switch {
case u != nil:
if s.disableEmails(ctx, u) {
return nil, nil
}
usr, err := s.getUser(ctx, u)
if err != nil {
return nil, err
}
if strings.TrimSpace(usr.GetMail()) == "" {
s.logger.Debug().Str("event", "getGranteeList").Msgf("User %s has no email, skipped", usr.GetUsername())
return nil, nil
}
return []*user.User{usr}, nil
case g != nil:
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return nil, err
}
res, err := gatewayClient.GetGroup(ctx, &group.GetGroupRequest{GroupId: g})
if err != nil {
return nil, err
}
if res.GetStatus().GetCode() != rpc.Code_CODE_OK {
return nil, errors.New("could not get group")
}
userList := make([]*user.User, 0, len(res.GetGroup().GetMembers()))
for _, userID := range res.GetGroup().GetMembers() {
// don't add the executant
if userID.GetOpaqueId() == executant.GetOpaqueId() {
continue
}
// don't add users who opted out
if s.disableEmails(ctx, userID) {
continue
}
usr, err := s.getUser(ctx, userID)
if err != nil {
return nil, err
}
if strings.TrimSpace(usr.GetMail()) == "" {
s.logger.Debug().Str("event", "getGranteeList").Msgf("User %s has no email, skipped", usr.GetUsername())
continue
}
userList = append(userList, usr)
}
return userList, nil
default:
return nil, errors.New("need at least one non-nil grantee")
}
}
func (s eventsNotifier) getUser(ctx context.Context, u *user.UserId) (*user.User, error) {
if u == nil {
return nil, errors.New("need at least one non-nil grantee")
}
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return nil, err
}
r, err := gatewayClient.GetUser(ctx, &user.GetUserRequest{UserId: u})
if err != nil {
return nil, err
}
if r.GetStatus().GetCode() != rpc.Code_CODE_OK {
return nil, fmt.Errorf("unexpected status code from gateway client: %d", r.GetStatus().GetCode())
}
return r.GetUser(), nil
}
func (s eventsNotifier) disableEmails(ctx context.Context, u *user.UserId) bool {
granteeCtx := metadata.Set(ctx, middleware.AccountID, u.OpaqueId)
if resp, err := s.valueService.GetValueByUniqueIdentifiers(granteeCtx,
&settingssvc.GetValueByUniqueIdentifiersRequest{
AccountUuid: u.OpaqueId,
SettingId: defaults.SettingUUIDProfileDisableNotifications,
},
); err == nil {
return resp.GetValue().GetValue().GetBoolValue()
}
return false
}
func (s eventsNotifier) getResourceInfo(ctx context.Context, resourceID *provider.ResourceId, fieldmask *fieldmaskpb.FieldMask) (*provider.ResourceInfo, error) {
// TODO: maybe cache this stat to reduce storage iops
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return nil, err
}
md, err := gatewayClient.Stat(ctx, &provider.StatRequest{
Ref: &provider.Reference{
ResourceId: resourceID,
},
FieldMask: fieldmask,
})
if err != nil {
return nil, err
}
if md.GetStatus().GetCode() != rpc.Code_CODE_OK {
return nil, fmt.Errorf("could not resource info: %s", md.Status.Message)
}
return md.GetInfo(), nil
}
// 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
}
@@ -0,0 +1,482 @@
package service_test
import (
"context"
"time"
settingsmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/settings/v0"
"github.com/opencloud-eu/reva/v2/pkg/store"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/shared"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
settingsmocks "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0/mocks"
"github.com/qsfera/server/services/graph/pkg/config/defaults"
"github.com/qsfera/server/services/notifications/pkg/channels"
"github.com/qsfera/server/services/notifications/pkg/service"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/utils"
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
)
var _ = Describe("Notifications", func() {
var (
gatewayClient *cs3mocks.GatewayAPIClient
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
vs *settingsmocks.ValueService
sharer = &user.User{
Id: &user.UserId{
OpaqueId: "sharer",
},
Mail: "sharer@qsfera.eu",
DisplayName: "Dr. S. Harer",
}
sharee = &user.User{
Id: &user.UserId{
OpaqueId: "sharee",
},
Mail: "sharee@qsfera.eu",
DisplayName: "Eric Expireling",
}
resourceid = &provider.ResourceId{
StorageId: "storageid",
SpaceId: "spaceid",
OpaqueId: "itemid",
}
)
BeforeEach(func() {
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
gatewayClient = &cs3mocks.GatewayAPIClient{}
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return gatewayClient
},
)
gatewayClient.On("GetUser", mock.Anything, mock.Anything).Return(&user.GetUserResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}, User: sharer}, nil).Once()
gatewayClient.On("GetUser", mock.Anything, mock.Anything).Return(&user.GetUserResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}, User: sharee}, nil).Once()
gatewayClient.On("Authenticate", mock.Anything, mock.Anything).Return(&gateway.AuthenticateResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}, User: sharer}, nil)
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(&provider.StatResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}, Info: &provider.ResourceInfo{Name: "secrets of the board", Space: &provider.StorageSpace{Name: "secret space"}}}, nil)
vs = &settingsmocks.ValueService{}
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(&settingssvc.GetValueResponse{
Value: &settingsmsg.ValueWithIdentifier{
Value: &settingsmsg.Value{
Value: &settingsmsg.Value_CollectionValue{
CollectionValue: &settingsmsg.CollectionValue{
Values: []*settingsmsg.CollectionOption{
{
Key: "mail",
Option: &settingsmsg.CollectionOption_BoolValue{BoolValue: true},
},
},
},
},
},
},
}, nil)
})
DescribeTable("Sending userEventIds",
func(tc testChannel, ev events.Event) {
cfg := defaults.FullDefaultConfig()
cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
ch := make(chan events.Event)
evts := service.NewEventsNotifier(ch, tc, log.NewLogger(), gatewaySelector, vs, "",
"", "", "", "", "", "",
store.Create(), nil, nil)
go evts.Run()
ch <- ev
select {
case <-tc.done:
// finished
case <-time.Tick(3 * time.Second):
Fail("timeout waiting for notification")
}
},
Entry("Share Created", testChannel{
expectedReceipients: []string{sharee.GetMail()},
expectedSubject: "Dr. S. Harer shared 'secrets of the board' with you",
expectedTextBody: `Hello Eric Expireling
Dr. S. Harer has shared "secrets of the board" with you.
Click here to view it: files/shares/with-me
---
КуСфера - a safe home for all your data
https://qsfera.eu
`,
expectedSender: sharer.GetDisplayName(),
done: make(chan struct{}),
}, events.Event{
Event: events.ShareCreated{
Sharer: sharer.GetId(),
GranteeUserID: sharee.GetId(),
CTime: utils.TimeToTS(time.Date(2023, 4, 17, 16, 42, 0, 0, time.UTC)),
ItemID: resourceid,
},
}),
Entry("Share Expired", testChannel{
expectedReceipients: []string{sharee.GetMail()},
expectedSubject: "Share to 'secrets of the board' expired at 2023-04-17 16:42:00",
expectedTextBody: `Hello Eric Expireling,
Your share to secrets of the board has expired at 2023-04-17 16:42:00
Even though this share has been revoked you still might have access through other shares and/or space memberships.
---
КуСфера - a safe home for all your data
https://qsfera.eu
`,
expectedSender: sharer.GetDisplayName(),
done: make(chan struct{}),
}, events.Event{
Event: events.ShareExpired{
ShareOwner: sharer.GetId(),
GranteeUserID: sharee.GetId(),
ExpiredAt: time.Date(2023, 4, 17, 16, 42, 0, 0, time.UTC),
ItemID: resourceid,
},
}),
Entry("Added to Space", testChannel{
expectedReceipients: []string{sharee.GetMail()},
expectedSubject: "Dr. S. Harer invited you to join secret space",
expectedTextBody: `Hello Eric Expireling,
Dr. S. Harer has invited you to join "secret space".
Click here to view it: f/spaceid
---
КуСфера - a safe home for all your data
https://qsfera.eu
`,
expectedSender: sharer.GetDisplayName(),
done: make(chan struct{}),
}, events.Event{
Event: events.SpaceShared{
Executant: sharer.GetId(),
Creator: sharer.GetId(),
GranteeUserID: sharee.GetId(),
ID: &provider.StorageSpaceId{OpaqueId: "spaceid"},
},
}),
Entry("Removed from Space", testChannel{
expectedReceipients: []string{sharee.GetMail()},
expectedSubject: "Dr. S. Harer removed you from secret space",
expectedTextBody: `Hello Eric Expireling,
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
---
КуСфера - a safe home for all your data
https://qsfera.eu
`,
expectedSender: sharer.GetDisplayName(),
done: make(chan struct{}),
}, events.Event{
Event: events.SpaceUnshared{
Executant: sharer.GetId(),
GranteeUserID: sharee.GetId(),
ID: &provider.StorageSpaceId{OpaqueId: "spaceid"},
},
}),
Entry("Space Expired", testChannel{
expectedReceipients: []string{sharee.GetMail()},
expectedSubject: "Membership of 'secret space' expired at 2023-04-17 16:42:00",
expectedTextBody: `Hello Eric Expireling,
Your membership of space secret space has expired at 2023-04-17 16:42:00
Even though this membership has expired you still might have access through other shares and/or space memberships
---
КуСфера - a safe home for all your data
https://qsfera.eu
`,
expectedSender: sharer.GetDisplayName(),
done: make(chan struct{}),
}, events.Event{
Event: events.SpaceMembershipExpired{
SpaceOwner: sharer.GetId(),
GranteeUserID: sharee.GetId(),
SpaceID: &provider.StorageSpaceId{OpaqueId: "spaceid"},
SpaceName: "secret space",
ExpiredAt: time.Date(2023, 4, 17, 16, 42, 0, 0, time.UTC),
},
}),
)
})
var _ = Describe("Notifications X-Site Scripting", func() {
var (
gatewayClient *cs3mocks.GatewayAPIClient
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
vs *settingsmocks.ValueService
sharer = &user.User{
Id: &user.UserId{
OpaqueId: "sharer",
},
Mail: "sharer@qsfera.eu",
DisplayName: "Dr. O'reilly",
}
sharee = &user.User{
Id: &user.UserId{
OpaqueId: "sharee",
},
Mail: "sharee@qsfera.eu",
DisplayName: "<script>alert('Eric Expireling');</script>",
}
resourceid = &provider.ResourceId{
StorageId: "storageid",
SpaceId: "spaceid",
OpaqueId: "itemid",
}
)
BeforeEach(func() {
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
gatewayClient = &cs3mocks.GatewayAPIClient{}
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return gatewayClient
},
)
gatewayClient.On("GetUser", mock.Anything, mock.Anything).Return(&user.GetUserResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}, User: sharer}, nil).Once()
gatewayClient.On("GetUser", mock.Anything, mock.Anything).Return(&user.GetUserResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}, User: sharee}, nil).Once()
gatewayClient.On("Authenticate", mock.Anything, mock.Anything).Return(&gateway.AuthenticateResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}, User: sharer}, nil)
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(&provider.StatResponse{
Status: &rpc.Status{Code: rpc.Code_CODE_OK},
Info: &provider.ResourceInfo{
Name: "<script>alert('secrets of the board');</script>",
Space: &provider.StorageSpace{Name: "<script>alert('secret space');</script>"}},
}, nil)
vs = &settingsmocks.ValueService{}
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(&settingssvc.GetValueResponse{
Value: &settingsmsg.ValueWithIdentifier{
Value: &settingsmsg.Value{
Value: &settingsmsg.Value_CollectionValue{
CollectionValue: &settingsmsg.CollectionValue{
Values: []*settingsmsg.CollectionOption{
{
Key: "mail",
Option: &settingsmsg.CollectionOption_BoolValue{BoolValue: true},
},
},
},
},
},
},
}, nil)
})
DescribeTable("Sending userEventIds",
func(tc testChannel, ev events.Event) {
cfg := defaults.FullDefaultConfig()
cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
ch := make(chan events.Event)
evts := service.NewEventsNotifier(ch, tc, log.NewLogger(), gatewaySelector, vs, "",
"", "", "", "", "", "",
store.Create(), nil, nil)
go evts.Run()
ch <- ev
select {
case <-tc.done:
// finished
case <-time.Tick(3 * time.Second):
Fail("timeout waiting for notification")
}
},
Entry("Share Created", testChannel{
expectedReceipients: []string{sharee.GetMail()},
expectedSubject: "Dr. O'reilly shared '<script>alert('secrets of the board');</script>' with you",
expectedTextBody: `Hello <script>alert('Eric Expireling');</script>
Dr. O'reilly has shared "<script>alert('secrets of the board');</script>" with you.
Click here to view it: files/shares/with-me
---
КуСфера - a safe home for all your data
https://qsfera.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 &lt;script&gt;alert(&#39;Eric Expireling&#39;);&lt;/script&gt;
<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>
</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>
КуСфера - a safe home for all your data<br>
<a href="https://qsfera.eu">https://qsfera.eu</a>
</footer>
</td>
</tr>
<tr>
<td colspan="2">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`,
expectedSender: sharer.GetDisplayName(),
done: make(chan struct{}),
}, events.Event{
Event: events.ShareCreated{
Sharer: sharer.GetId(),
GranteeUserID: sharee.GetId(),
CTime: utils.TimeToTS(time.Date(2023, 4, 17, 16, 42, 0, 0, time.UTC)),
ItemID: resourceid,
},
}),
Entry("Added to Space", testChannel{
expectedReceipients: []string{sharee.GetMail()},
expectedSubject: "Dr. O'reilly invited you to join <script>alert('secret space');</script>",
expectedTextBody: `Hello <script>alert('Eric Expireling');</script>,
Dr. O'reilly has invited you to join "<script>alert('secret space');</script>".
Click here to view it: f/spaceid
---
КуСфера - a safe home for all your data
https://qsfera.eu
`,
expectedSender: sharer.GetDisplayName(),
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 &lt;script&gt;alert(&#39;Eric Expireling&#39;);&lt;/script&gt;,
<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>
</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>
КуСфера - a safe home for all your data<br>
<a href="https://qsfera.eu">https://qsfera.eu</a>
</footer>
</td>
</tr>
<tr>
<td colspan="2">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`,
done: make(chan struct{}),
}, events.Event{
Event: events.SpaceShared{
Executant: sharer.GetId(),
Creator: sharer.GetId(),
GranteeUserID: sharee.GetId(),
ID: &provider.StorageSpaceId{OpaqueId: "spaceid"},
},
}),
)
})
// NOTE: This is explictitly not testing the message itself. Should we?
type testChannel struct {
expectedReceipients []string
expectedSubject string
expectedTextBody string
expectedHTMLBody string
expectedSender string
done chan struct{}
}
func (tc testChannel) SendMessage(ctx context.Context, m *channels.Message) error {
defer GinkgoRecover()
Expect(tc.expectedReceipients).To(Equal(m.Recipient))
Expect(tc.expectedSubject).To(Equal(m.Subject))
Expect(tc.expectedTextBody).To(Equal(m.TextBody))
Expect(tc.expectedSender).To(Equal(m.Sender))
if tc.expectedHTMLBody != "" {
Expect(tc.expectedHTMLBody).To(Equal(m.HTMLBody))
}
tc.done <- struct{}{}
return nil
}
@@ -0,0 +1,163 @@
package service
import (
"context"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/qsfera/server/services/notifications/pkg/email"
"github.com/qsfera/server/services/settings/pkg/store/defaults"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"github.com/rs/zerolog"
"google.golang.org/protobuf/types/known/fieldmaskpb"
)
func (s eventsNotifier) handleShareCreated(e events.ShareCreated, eventId string) {
logger := s.logger.With().
Str("event", "ShareCreated").
Str("itemid", e.ItemID.OpaqueId).
Logger()
owner, shareFolder, shareLink, ctx, err := s.prepareShareCreated(logger, e)
if err != nil {
logger.Error().Err(err).Msg("could not prepare vars for email")
return
}
granteeList := s.ensureGranteeList(ctx, owner.GetId(), e.GranteeUserID, e.GranteeGroupID)
filteredGrantees := s.filter.execute(ctx, granteeList, defaults.SettingUUIDProfileEventShareCreated)
recipientsInstant, recipientsDaily, recipientsInstantWeekly := s.splitter.execute(ctx, filteredGrantees)
recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalDaily, eventId, recipientsDaily)...)
recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalWeekly, eventId, recipientsInstantWeekly)...)
if recipientsInstant == nil {
return
}
sharerDisplayName := owner.GetDisplayName()
emails, err := s.render(ctx, email.ShareCreated,
"ShareGrantee",
map[string]string{
"ShareSharer": sharerDisplayName,
"ShareFolder": shareFolder,
"ShareLink": shareLink,
}, recipientsInstant, sharerDisplayName)
if err != nil {
logger.Error().Err(err).Msg("could not get render the email")
return
}
s.send(ctx, emails)
}
func (s eventsNotifier) prepareShareCreated(logger zerolog.Logger, e events.ShareCreated) (owner *user.User, shareFolder, shareLink string, ctx context.Context, err error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
logger.Error().Err(err).Msg("could not select next gateway client")
return owner, shareFolder, shareLink, ctx, err
}
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, shareFolder, shareLink, ctx, err
}
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 owner, shareFolder, shareLink, ctx, err
}
shareFolder = resourceInfo.Name
shareLink, err = urlJoinPath(s.qsferaURL, "files/shares/with-me")
if err != nil {
logger.Error().
Err(err).
Msg("could not create link to the share")
return owner, shareFolder, shareLink, ctx, err
}
owner, err = utils.GetUserNoGroups(ctx, e.Sharer, gatewayClient)
if err != nil {
logger.Error().
Err(err).
Msg("could not get user")
return owner, shareFolder, shareLink, ctx, err
}
return owner, shareFolder, shareLink, ctx, err
}
func (s eventsNotifier) handleShareExpired(e events.ShareExpired, eventId string) {
logger := s.logger.With().
Str("event", "ShareExpired").
Str("itemid", e.ItemID.GetOpaqueId()).
Logger()
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
logger.Error().Err(err).Msg("could not select next gateway client")
return
}
shareFolder, ctx, err := s.prepareShareExpired(logger, e)
if err != nil {
logger.Error().Err(err).Msg("could not prepare vars for email")
return
}
owner, err := utils.GetUserNoGroups(ctx, e.ShareOwner, gatewayClient)
if err != nil {
logger.Error().Err(err).Msg("Could not get user")
return
}
granteeList := s.ensureGranteeList(ctx, owner.GetId(), e.GranteeUserID, e.GranteeGroupID)
filteredGrantees := s.filter.execute(ctx, granteeList, defaults.SettingUUIDProfileEventShareExpired)
recipientsInstant, recipientsDaily, recipientsInstantWeekly := s.splitter.execute(ctx, filteredGrantees)
recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalDaily, eventId, recipientsDaily)...)
recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalWeekly, eventId, recipientsInstantWeekly)...)
if recipientsInstant == nil {
return
}
emails, err := s.render(ctx, email.ShareExpired,
"ShareGrantee",
map[string]string{
"ShareFolder": shareFolder,
"ExpiredAt": e.ExpiredAt.Format("2006-01-02 15:04:05"),
}, recipientsInstant, owner.GetDisplayName())
if err != nil {
logger.Error().Err(err).Msg("could not get render the email")
return
}
s.send(ctx, emails)
}
func (s eventsNotifier) prepareShareExpired(logger zerolog.Logger, e events.ShareExpired) (shareFolder string, ctx context.Context, err error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
logger.Error().Err(err).Msg("could not select next gateway client")
return shareFolder, ctx, err
}
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 shareFolder, ctx, err
}
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, ctx, err
}
shareFolder = resourceInfo.GetName()
return shareFolder, ctx, err
}
@@ -0,0 +1,234 @@
package service
import (
"context"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/qsfera/server/services/notifications/pkg/email"
"github.com/qsfera/server/services/settings/pkg/store/defaults"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"github.com/rs/zerolog"
)
func (s eventsNotifier) handleSpaceShared(e events.SpaceShared, eventId string) {
logger := s.logger.With().
Str("event", "SpaceShared").
Str("itemid", e.ID.OpaqueId).
Logger()
executant, spaceName, shareLink, ctx, err := s.prepareSpaceShared(logger, e)
if err != nil {
logger.Error().Err(err).Msg("could not prepare vars for email")
return
}
granteeList := s.ensureGranteeList(ctx, executant.GetId(), e.GranteeUserID, e.GranteeGroupID)
filteredGrantees := s.filter.execute(ctx, granteeList, defaults.SettingUUIDProfileEventSpaceShared)
recipientsInstant, recipientsDaily, recipientsInstantWeekly := s.splitter.execute(ctx, filteredGrantees)
recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalDaily, eventId, recipientsDaily)...)
recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalWeekly, eventId, recipientsInstantWeekly)...)
if recipientsInstant == nil {
return
}
sharerDisplayName := executant.GetDisplayName()
emails, err := s.render(ctx, email.SharedSpace,
"SpaceGrantee",
map[string]string{
"SpaceSharer": sharerDisplayName,
"SpaceName": spaceName,
"ShareLink": shareLink,
}, recipientsInstant, sharerDisplayName)
if err != nil {
logger.Error().Err(err).Msg("could not get render the email")
return
}
s.send(ctx, emails)
}
func (s eventsNotifier) prepareSpaceShared(logger zerolog.Logger, e events.SpaceShared) (executant *user.User, spaceName, shareLink string, ctx context.Context, err error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
logger.Error().Err(err).Msg("could not select next gateway client")
return executant, spaceName, shareLink, ctx, err
}
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 executant, spaceName, shareLink, ctx, err
}
executant, err = utils.GetUserNoGroups(ctx, e.Executant, gatewayClient)
if err != nil {
logger.Error().
Err(err).
Msg("could not get user")
return executant, spaceName, shareLink, ctx, err
}
resourceID, err := storagespace.ParseID(e.ID.OpaqueId)
if err != nil {
logger.Error().
Err(err).
Msg("could not parse resourceid from ItemID ")
return executant, spaceName, shareLink, ctx, err
}
resourceInfo, err := s.getResourceInfo(ctx, &resourceID, nil)
if err != nil {
logger.Error().
Err(err).
Msg("could not get space info")
return executant, spaceName, shareLink, ctx, err
}
spaceName = resourceInfo.GetSpace().GetName()
shareLink, err = urlJoinPath(s.qsferaURL, "f", e.ID.OpaqueId)
if err != nil {
logger.Error().
Err(err).
Msg("could not create link to the share")
return executant, spaceName, shareLink, ctx, err
}
return executant, spaceName, shareLink, ctx, err
}
func (s eventsNotifier) handleSpaceUnshared(e events.SpaceUnshared, eventId string) {
logger := s.logger.With().
Str("event", "SpaceUnshared").
Str("itemid", e.ID.OpaqueId).
Logger()
executant, spaceName, shareLink, ctx, err := s.prepareSpaceUnshared(logger, e)
if err != nil {
logger.Error().Err(err).Msg("could not prepare vars for email")
return
}
granteeList := s.ensureGranteeList(ctx, executant.GetId(), e.GranteeUserID, e.GranteeGroupID)
filteredGrantees := s.filter.execute(ctx, granteeList, defaults.SettingUUIDProfileEventSpaceUnshared)
recipientsInstant, recipientsDaily, recipientsInstantWeekly := s.splitter.execute(ctx, filteredGrantees)
recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalDaily, eventId, recipientsDaily)...)
recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalWeekly, eventId, recipientsInstantWeekly)...)
if recipientsInstant == nil {
return
}
sharerDisplayName := executant.GetDisplayName()
emails, err := s.render(ctx, email.UnsharedSpace,
"SpaceGrantee",
map[string]string{
"SpaceSharer": sharerDisplayName,
"SpaceName": spaceName,
"ShareLink": shareLink,
}, recipientsInstant, sharerDisplayName)
if err != nil {
logger.Error().Err(err).Msg("Could not get render the email")
return
}
s.send(ctx, emails)
}
func (s eventsNotifier) prepareSpaceUnshared(logger zerolog.Logger, e events.SpaceUnshared) (executant *user.User, spaceName, shareLink string, ctx context.Context, err error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
logger.Error().Err(err).Msg("could not select next gateway client")
return executant, spaceName, shareLink, ctx, err
}
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 executant, spaceName, shareLink, ctx, err
}
executant, err = utils.GetUserNoGroups(ctx, e.Executant, gatewayClient)
if err != nil {
logger.Error().
Err(err).
Msg("could not get user")
return executant, spaceName, shareLink, ctx, err
}
resourceID, err := storagespace.ParseID(e.ID.OpaqueId)
if err != nil {
logger.Error().
Err(err).
Msg("could not parse resourceid from ItemID ")
return executant, spaceName, shareLink, ctx, err
}
resourceInfo, err := s.getResourceInfo(ctx, &resourceID, nil)
if err != nil {
logger.Error().
Err(err).
Msg("could not get space info")
return executant, spaceName, shareLink, ctx, err
}
spaceName = resourceInfo.GetSpace().GetName()
shareLink, err = urlJoinPath(s.qsferaURL, "f", e.ID.OpaqueId)
if err != nil {
logger.Error().
Err(err).
Msg("could not create link to the share")
return executant, spaceName, shareLink, ctx, err
}
return executant, spaceName, shareLink, ctx, err
}
func (s eventsNotifier) handleSpaceMembershipExpired(e events.SpaceMembershipExpired, eventId string) {
logger := s.logger.With().
Str("event", "SpaceMembershipExpired").
Str("itemid", e.SpaceID.GetOpaqueId()).
Logger()
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
logger.Error().Err(err).Msg("could not select next gateway client")
return
}
ctx, err := utils.GetServiceUserContext(s.serviceAccountID, gatewayClient, s.serviceAccountSecret)
if err != nil {
logger.Error().Err(err).Msg("Could not impersonate sharer")
return
}
owner, err := utils.GetUserNoGroups(ctx, e.SpaceOwner, gatewayClient)
if err != nil {
logger.Error().
Err(err).
Msg("could not get user")
return
}
granteeList := s.ensureGranteeList(ctx, owner.GetId(), e.GranteeUserID, e.GranteeGroupID)
if granteeList == nil {
return
}
filteredGrantees := s.filter.execute(ctx, granteeList, defaults.SettingUUIDProfileEventSpaceMembershipExpired)
recipientsInstant, recipientsDaily, recipientsInstantWeekly := s.splitter.execute(ctx, filteredGrantees)
recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalDaily, eventId, recipientsDaily)...)
recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalWeekly, eventId, recipientsInstantWeekly)...)
if recipientsInstant == nil {
return
}
emails, err := s.render(ctx, email.MembershipExpired,
"SpaceGrantee",
map[string]string{
"SpaceName": e.SpaceName,
"ExpiredAt": e.ExpiredAt.Format("2006-01-02 15:04:05"),
}, recipientsInstant, owner.GetDisplayName())
if err != nil {
logger.Error().Err(err).Msg("could not get render the email")
return
}
s.send(ctx, emails)
}
@@ -0,0 +1,61 @@
package service
import (
"context"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/middleware"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/settings/pkg/store/defaults"
"github.com/pkg/errors"
micrometadata "go-micro.dev/v4/metadata"
)
type intervalSplitter struct {
log log.Logger
valueClient settingssvc.ValueService
}
func newIntervalSplitter(l log.Logger, vc settingssvc.ValueService) *intervalSplitter {
return &intervalSplitter{log: l, valueClient: vc}
}
// execute splits users into 3 lists depending on their email sending interval settings
func (s intervalSplitter) execute(ctx context.Context, users []*user.User) (instant, daily, weekly []*user.User) {
for _, u := range users {
userId := u.GetId().GetOpaqueId()
interval, err := getEmailSendingInterval(ctx, s.valueClient, userId)
if err != nil {
s.log.Error().Err(err).Str("userId", userId).Msg("cannot get user email sending interval")
instant = append(instant, u)
} else if interval == "instant" {
instant = append(instant, u)
} else if interval == _intervalDaily {
daily = append(daily, u)
} else if interval == _intervalWeekly {
weekly = append(weekly, u)
}
}
return
}
func getEmailSendingInterval(ctx context.Context, vc settingssvc.ValueService, userId string) (string, error) {
resp, err := vc.GetValueByUniqueIdentifiers(
micrometadata.Set(ctx, middleware.AccountID, userId),
&settingssvc.GetValueByUniqueIdentifiersRequest{
AccountUuid: userId,
SettingId: defaults.SettingUUIDProfileEmailSendingInterval,
},
)
if err != nil {
return "", err
}
val := resp.GetValue().GetValue().GetStringValue()
if val == "" {
return "", errors.New("email sending interval is empty")
}
return val, nil
}
@@ -0,0 +1,146 @@
package service
import (
"context"
"strings"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/pkg/errors"
"github.com/stretchr/testify/mock"
"go-micro.dev/v4/client"
"github.com/qsfera/server/pkg/log"
settingsmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/settings/v0"
settings "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
settingsmocks "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0/mocks"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("NotificationFilter", func() {
var (
testLogger = log.NewLogger()
vs *settingsmocks.ValueService
s intervalSplitter
)
BeforeEach(func() {
vs = &settingsmocks.ValueService{}
s = intervalSplitter{
log: testLogger,
valueClient: vs,
}
})
It("handles connection errors", func() {
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(nil, errors.New("no connection to ValueService"))
instant, daily, weekly := s.execute(context.TODO(), newUsers("foo"))
Expect(instant).To(Equal(newUsers("foo")))
Expect(daily).To(BeEmpty())
Expect(weekly).To(BeEmpty())
})
It("handles no setting in ValueService response", func() {
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(&settings.GetValueResponse{}, nil)
instant, daily, weekly := s.execute(context.TODO(), newUsers("foo"))
Expect(instant).To(Equal(newUsers("foo")))
Expect(daily).To(BeEmpty())
Expect(weekly).To(BeEmpty())
})
It("handles nil response", func() {
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(nil, nil)
instant, daily, weekly := s.execute(context.TODO(), newUsers("foo"))
Expect(instant).To(Equal(newUsers("foo")))
Expect(daily).To(BeEmpty())
Expect(weekly).To(BeEmpty())
})
It("handles nil input user", func() {
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(nil, nil)
instant, daily, weekly := s.execute(context.TODO(), nil)
Expect(instant).To(BeEmpty())
Expect(daily).To(BeEmpty())
Expect(weekly).To(BeEmpty())
})
It("handles never interval", func() {
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(newGetValueResponseStringValue("never"), nil)
instant, daily, weekly := s.execute(context.TODO(), newUsers("foo"))
Expect(instant).To(BeEmpty())
Expect(daily).To(BeEmpty())
Expect(weekly).To(BeEmpty())
})
It("handles instant interval", func() {
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(newGetValueResponseStringValue("instant"), nil)
instant, daily, weekly := s.execute(context.TODO(), newUsers("foo"))
Expect(instant).To(Equal(newUsers("foo")))
Expect(daily).To(BeEmpty())
Expect(weekly).To(BeEmpty())
})
It("handles daily interval", func() {
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(newGetValueResponseStringValue("daily"), nil)
instant, daily, weekly := s.execute(context.TODO(), newUsers("foo"))
Expect(instant).To(BeEmpty())
Expect(daily).To(Equal(newUsers("foo")))
Expect(weekly).To(BeEmpty())
})
It("handles weekly interval", func() {
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(newGetValueResponseStringValue("weekly"), nil)
instant, daily, weekly := s.execute(context.TODO(), newUsers("foo"))
Expect(instant).To(BeEmpty())
Expect(daily).To(BeEmpty())
Expect(weekly).To(Equal(newUsers("foo")))
})
It("handles multiple users and intervals", func() {
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(func(ctx context.Context, req *settings.GetValueByUniqueIdentifiersRequest, opts ...client.CallOption) *settings.GetValueResponse {
if strings.Contains(req.AccountUuid, "never") {
return newGetValueResponseStringValue("never")
} else if strings.Contains(req.AccountUuid, "instant") {
return newGetValueResponseStringValue("instant")
} else if strings.Contains(req.AccountUuid, "daily") {
return newGetValueResponseStringValue("daily")
} else if strings.Contains(req.AccountUuid, "weekly") {
return newGetValueResponseStringValue("weekly")
}
return nil
}, nil)
instant, daily, weekly := s.execute(context.TODO(), newUsers("never1", "instant1", "daily1", "weekly1", "never2", "instant2", "daily2", "weekly2"))
Expect(instant).To(Equal(newUsers("instant1", "instant2")))
Expect(daily).To(Equal(newUsers("daily1", "daily2")))
Expect(weekly).To(Equal(newUsers("weekly1", "weekly2")))
})
})
func newGetValueResponseStringValue(strVal string) *settings.GetValueResponse {
return &settings.GetValueResponse{Value: &settingsmsg.ValueWithIdentifier{
Value: &settingsmsg.Value{
Value: &settingsmsg.Value_StringValue{
StringValue: strVal,
},
},
}}
}
func newUsers(ids ...string) []*user.User {
var users []*user.User
for _, s := range ids {
users = append(users, &user.User{Id: &user.UserId{OpaqueId: s}})
}
return users
}