chore: move the go code into a subfolder

This commit is contained in:
Florian Forster
2025-08-05 15:20:32 -07:00
parent 4ad22ba456
commit cd2921de26
2978 changed files with 373 additions and 300 deletions

View File

@@ -0,0 +1,118 @@
package notification
import (
"context"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/notification/channels/email"
"github.com/zitadel/zitadel/internal/notification/channels/set"
"github.com/zitadel/zitadel/internal/notification/channels/sms"
"github.com/zitadel/zitadel/internal/notification/channels/webhook"
"github.com/zitadel/zitadel/internal/notification/handlers"
"github.com/zitadel/zitadel/internal/notification/senders"
"github.com/zitadel/zitadel/internal/notification/types"
"github.com/zitadel/zitadel/internal/telemetry/metrics"
)
var _ types.ChannelChains = (*channels)(nil)
type counters struct {
success deliveryMetrics
failed deliveryMetrics
}
type deliveryMetrics struct {
email string
sms string
json string
}
type channels struct {
q *handlers.NotificationQueries
counters counters
}
func newChannels(q *handlers.NotificationQueries) *channels {
c := &channels{
q: q,
counters: counters{
success: deliveryMetrics{
email: "successful_deliveries_email",
sms: "successful_deliveries_sms",
json: "successful_deliveries_json",
},
failed: deliveryMetrics{
email: "failed_deliveries_email",
sms: "failed_deliveries_sms",
json: "failed_deliveries_json",
},
},
}
registerCounter(c.counters.success.email, "Successfully delivered emails")
registerCounter(c.counters.failed.email, "Failed email deliveries")
registerCounter(c.counters.success.sms, "Successfully delivered SMS")
registerCounter(c.counters.failed.sms, "Failed SMS deliveries")
registerCounter(c.counters.success.json, "Successfully delivered JSON messages")
registerCounter(c.counters.failed.json, "Failed JSON message deliveries")
return c
}
func registerCounter(counter, desc string) {
err := metrics.RegisterCounter(counter, desc)
logging.WithFields("metric", counter).OnError(err).Panic("unable to register counter")
}
func (c *channels) Email(ctx context.Context) (*senders.Chain, *email.Config, error) {
emailCfg, err := c.q.GetActiveEmailConfig(ctx)
if err != nil {
return nil, nil, err
}
chain, err := senders.EmailChannels(
ctx,
emailCfg,
c.q.GetFileSystemProvider,
c.q.GetLogProvider,
c.counters.success.email,
c.counters.failed.email,
)
return chain, emailCfg, err
}
func (c *channels) SMS(ctx context.Context) (*senders.Chain, *sms.Config, error) {
smsCfg, err := c.q.GetActiveSMSConfig(ctx)
if err != nil {
return nil, nil, err
}
chain, err := senders.SMSChannels(
ctx,
smsCfg,
c.q.GetFileSystemProvider,
c.q.GetLogProvider,
c.counters.success.sms,
c.counters.failed.sms,
)
return chain, smsCfg, err
}
func (c *channels) Webhook(ctx context.Context, cfg webhook.Config) (*senders.Chain, error) {
return senders.WebhookChannels(
ctx,
cfg,
c.q.GetFileSystemProvider,
c.q.GetLogProvider,
c.counters.success.json,
c.counters.failed.json,
)
}
func (c *channels) SecurityTokenEvent(ctx context.Context, cfg set.Config) (*senders.Chain, error) {
return senders.SecurityEventTokenChannels(
ctx,
cfg,
c.q.GetFileSystemProvider,
c.q.GetLogProvider,
c.counters.success.json,
c.counters.failed.json,
)
}

View File

@@ -0,0 +1,20 @@
package channels
import "github.com/zitadel/zitadel/internal/eventstore"
type Message interface {
GetTriggeringEventType() eventstore.EventType
GetContent() (string, error)
}
type NotificationChannel interface {
HandleMessage(message Message) error
}
var _ NotificationChannel = (HandleMessageFunc)(nil)
type HandleMessageFunc func(message Message) error
func (h HandleMessageFunc) HandleMessage(message Message) error {
return h(message)
}

View File

@@ -0,0 +1,17 @@
package email
import (
"github.com/zitadel/zitadel/internal/notification/channels/smtp"
"github.com/zitadel/zitadel/internal/notification/channels/webhook"
)
type Config struct {
ProviderConfig *Provider
SMTPConfig *smtp.Config
WebhookConfig *webhook.Config
}
type Provider struct {
ID string `json:"id,omitempty"`
Description string `json:"description,omitempty"`
}

View File

@@ -0,0 +1,25 @@
package channels
import "errors"
type CancelError struct {
Err error
}
func (e *CancelError) Error() string {
return e.Err.Error()
}
func NewCancelError(err error) error {
return &CancelError{
Err: err,
}
}
func (e *CancelError) Is(target error) bool {
return errors.As(target, &e)
}
func (e *CancelError) Unwrap() error {
return e.Err
}

View File

@@ -0,0 +1,52 @@
package fs
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/k3a/html2text"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/notification/channels"
"github.com/zitadel/zitadel/internal/notification/messages"
"github.com/zitadel/zitadel/internal/zerrors"
)
func InitFSChannel(config Config) (channels.NotificationChannel, error) {
if err := os.MkdirAll(config.Path, os.ModePerm); err != nil {
return nil, err
}
logging.Debug("successfully initialized filesystem email and sms channel")
return channels.HandleMessageFunc(func(message channels.Message) error {
fileName := fmt.Sprintf("%d_", time.Now().Unix())
content, err := message.GetContent()
if err != nil {
return err
}
switch msg := message.(type) {
case *messages.Email:
recipients := make([]string, len(msg.Recipients))
copy(recipients, msg.Recipients)
sort.Strings(recipients)
fileName = fileName + "mail_to_" + strings.Join(recipients, "_") + ".html"
if config.Compact {
content = html2text.HTML2Text(content)
}
case *messages.SMS:
fileName = fileName + "sms_to_" + msg.RecipientPhoneNumber + ".txt"
case *messages.JSON:
fileName = "message.json"
default:
return zerrors.ThrowUnimplementedf(nil, "NOTIF-6f9a1", "filesystem provider doesn't support message type %T", message)
}
return os.WriteFile(filepath.Join(config.Path, fileName), []byte(content), 0666)
}), nil
}

View File

@@ -0,0 +1,7 @@
package fs
type Config struct {
Enabled bool
Compact bool
Path string
}

View File

@@ -0,0 +1,4 @@
package channels
//go:generate mockgen -package mock -destination ./mock/channel.mock.go github.com/zitadel/zitadel/internal/notification/channels NotificationChannel
//go:generate mockgen -package mock -destination ./mock/message.mock.go github.com/zitadel/zitadel/internal/notification/channels Message

View File

@@ -0,0 +1,26 @@
package instrumenting
import (
"context"
"github.com/zitadel/zitadel/internal/notification/channels"
)
func Wrap(
ctx context.Context,
channel channels.NotificationChannel,
traceSpanName,
successMetricName,
failureMetricName string,
) channels.NotificationChannel {
return traceMessages(
ctx,
countMessages(
ctx,
logMessages(ctx, channel),
successMetricName,
failureMetricName,
),
traceSpanName,
)
}

View File

@@ -0,0 +1,24 @@
package instrumenting
import (
"context"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/notification/channels"
)
func logMessages(ctx context.Context, channel channels.NotificationChannel) channels.NotificationChannel {
return channels.HandleMessageFunc(func(message channels.Message) error {
logEntry := logging.WithFields(
"instance", authz.GetInstance(ctx).InstanceID(),
"triggering_event_type", message.GetTriggeringEventType(),
)
logEntry.Debug("sending notification")
err := channel.HandleMessage(message)
logEntry.OnError(err).Warn("sending notification failed")
logEntry.Debug("notification sent")
return err
})
}

View File

@@ -0,0 +1,31 @@
package instrumenting
import (
"context"
"github.com/zitadel/logging"
"go.opentelemetry.io/otel/attribute"
"github.com/zitadel/zitadel/internal/notification/channels"
"github.com/zitadel/zitadel/internal/telemetry/metrics"
)
func countMessages(ctx context.Context, channel channels.NotificationChannel, successMetricName, errorMetricName string) channels.NotificationChannel {
return channels.HandleMessageFunc(func(message channels.Message) error {
err := channel.HandleMessage(message)
metricName := successMetricName
if err != nil {
metricName = errorMetricName
}
addCount(ctx, metricName, message)
return err
})
}
func addCount(ctx context.Context, metricName string, message channels.Message) {
labels := map[string]attribute.Value{
"triggering_event_type": attribute.StringValue(string(message.GetTriggeringEventType())),
}
addCountErr := metrics.AddCount(ctx, metricName, 1, labels)
logging.WithFields("name", metricName, "labels", labels).OnError(addCountErr).Error("incrementing counter metric failed")
}

View File

@@ -0,0 +1,16 @@
package instrumenting
import (
"context"
"github.com/zitadel/zitadel/internal/notification/channels"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
)
func traceMessages(ctx context.Context, channel channels.NotificationChannel, spanName string) channels.NotificationChannel {
return channels.HandleMessageFunc(func(message channels.Message) (err error) {
_, span := tracing.NewNamedSpan(ctx, spanName)
defer func() { span.EndWithError(err) }()
return channel.HandleMessage(message)
})
}

View File

@@ -0,0 +1,32 @@
package log
import (
"fmt"
"github.com/k3a/html2text"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/notification/channels"
)
func InitStdoutChannel(config Config) channels.NotificationChannel {
logging.WithFields("logID", "NOTIF-D0164").Debug("successfully initialized stdout email and sms channel")
return channels.HandleMessageFunc(func(message channels.Message) error {
content, err := message.GetContent()
if err != nil {
return err
}
if config.Compact {
content = html2text.HTML2Text(content)
}
logging.WithFields("logID", "NOTIF-c73ba").WithFields(map[string]interface{}{
"type": fmt.Sprintf("%T", message),
"content": content,
}).Info("handling notification message")
return nil
})
}

View File

@@ -0,0 +1,6 @@
package log
type Config struct {
Enabled bool
Compact bool
}

View File

@@ -0,0 +1,54 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/zitadel/zitadel/internal/notification/channels (interfaces: NotificationChannel)
//
// Generated by this command:
//
// mockgen -package mock -destination ./mock/channel.mock.go github.com/zitadel/zitadel/internal/notification/channels NotificationChannel
//
// Package mock is a generated GoMock package.
package mock
import (
reflect "reflect"
channels "github.com/zitadel/zitadel/internal/notification/channels"
gomock "go.uber.org/mock/gomock"
)
// MockNotificationChannel is a mock of NotificationChannel interface.
type MockNotificationChannel struct {
ctrl *gomock.Controller
recorder *MockNotificationChannelMockRecorder
}
// MockNotificationChannelMockRecorder is the mock recorder for MockNotificationChannel.
type MockNotificationChannelMockRecorder struct {
mock *MockNotificationChannel
}
// NewMockNotificationChannel creates a new mock instance.
func NewMockNotificationChannel(ctrl *gomock.Controller) *MockNotificationChannel {
mock := &MockNotificationChannel{ctrl: ctrl}
mock.recorder = &MockNotificationChannelMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockNotificationChannel) EXPECT() *MockNotificationChannelMockRecorder {
return m.recorder
}
// HandleMessage mocks base method.
func (m *MockNotificationChannel) HandleMessage(arg0 channels.Message) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "HandleMessage", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// HandleMessage indicates an expected call of HandleMessage.
func (mr *MockNotificationChannelMockRecorder) HandleMessage(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandleMessage", reflect.TypeOf((*MockNotificationChannel)(nil).HandleMessage), arg0)
}

View File

@@ -0,0 +1,69 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/zitadel/zitadel/internal/notification/channels (interfaces: Message)
//
// Generated by this command:
//
// mockgen -package mock -destination ./mock/message.mock.go github.com/zitadel/zitadel/internal/notification/channels Message
//
// Package mock is a generated GoMock package.
package mock
import (
reflect "reflect"
eventstore "github.com/zitadel/zitadel/internal/eventstore"
gomock "go.uber.org/mock/gomock"
)
// MockMessage is a mock of Message interface.
type MockMessage struct {
ctrl *gomock.Controller
recorder *MockMessageMockRecorder
}
// MockMessageMockRecorder is the mock recorder for MockMessage.
type MockMessageMockRecorder struct {
mock *MockMessage
}
// NewMockMessage creates a new mock instance.
func NewMockMessage(ctrl *gomock.Controller) *MockMessage {
mock := &MockMessage{ctrl: ctrl}
mock.recorder = &MockMessageMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockMessage) EXPECT() *MockMessageMockRecorder {
return m.recorder
}
// GetContent mocks base method.
func (m *MockMessage) GetContent() (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetContent")
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetContent indicates an expected call of GetContent.
func (mr *MockMessageMockRecorder) GetContent() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetContent", reflect.TypeOf((*MockMessage)(nil).GetContent))
}
// GetTriggeringEvent mocks base method.
func (m *MockMessage) GetTriggeringEvent() eventstore.Event {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetTriggeringEvent")
ret0, _ := ret[0].(eventstore.Event)
return ret0
}
// GetTriggeringEvent indicates an expected call of GetTriggeringEvent.
func (mr *MockMessageMockRecorder) GetTriggeringEvent() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTriggeringEvent", reflect.TypeOf((*MockMessage)(nil).GetTriggeringEvent))
}

View File

@@ -0,0 +1,75 @@
package set
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"time"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/notification/channels"
"github.com/zitadel/zitadel/internal/notification/messages"
"github.com/zitadel/zitadel/internal/zerrors"
)
func InitChannel(ctx context.Context, cfg Config) (channels.NotificationChannel, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
logging.Debug("successfully initialized security event token json channel")
return channels.HandleMessageFunc(func(message channels.Message) error {
requestCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
msg, ok := message.(*messages.Form)
if !ok {
return zerrors.ThrowInternal(nil, "SET-K686U", "message is not SET")
}
payload, err := msg.GetContent()
if err != nil {
return err
}
req, err := http.NewRequestWithContext(requestCtx, http.MethodPost, cfg.CallURL, strings.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
logging.WithFields("instanceID", authz.GetInstance(ctx).InstanceID(), "calling_url", cfg.CallURL).Debug("security event token called")
if resp.StatusCode == http.StatusOK ||
resp.StatusCode == http.StatusAccepted ||
resp.StatusCode == http.StatusNoContent {
return nil
}
body, err := mapResponse(resp)
logging.WithFields("instanceID", authz.GetInstance(ctx).InstanceID(), "callURL", cfg.CallURL).
OnError(err).Debug("error mapping response")
if resp.StatusCode == http.StatusBadRequest {
logging.WithFields("instanceID", authz.GetInstance(ctx).InstanceID(), "callURL", cfg.CallURL, "status", resp.Status, "body", body).
Error("security event token didn't return a success status")
return nil
}
return zerrors.ThrowInternalf(err, "SET-DF3dq", "security event token to %s didn't return a success status: %s (%v)", cfg.CallURL, resp.Status, body)
}), nil
}
func mapResponse(resp *http.Response) (map[string]any, error) {
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
requestError := make(map[string]any)
err = json.Unmarshal(body, &requestError)
if err != nil {
return nil, err
}
return requestError, nil
}

View File

@@ -0,0 +1,14 @@
package set
import (
"net/url"
)
type Config struct {
CallURL string
}
func (w *Config) Validate() error {
_, err := url.Parse(w.CallURL)
return err
}

View File

@@ -0,0 +1,17 @@
package sms
import (
"github.com/zitadel/zitadel/internal/notification/channels/twilio"
"github.com/zitadel/zitadel/internal/notification/channels/webhook"
)
type Config struct {
ProviderConfig *Provider
TwilioConfig *twilio.Config
WebhookConfig *webhook.Config
}
type Provider struct {
ID string `json:"id,omitempty"`
Description string `json:"description,omitempty"`
}

View File

@@ -0,0 +1,209 @@
package smtp
import (
"crypto/tls"
"errors"
"net"
"net/smtp"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/notification/channels"
"github.com/zitadel/zitadel/internal/notification/messages"
"github.com/zitadel/zitadel/internal/zerrors"
)
var _ channels.NotificationChannel = (*Email)(nil)
type Email struct {
smtpClient *smtp.Client
senderAddress string
senderName string
replyToAddress string
}
func InitChannel(cfg *Config) (*Email, error) {
client, err := cfg.SMTP.connectToSMTP(cfg.Tls)
if err != nil {
logging.New().WithError(err).Error("could not connect to smtp")
return nil, err
}
logging.New().Debug("successfully initialized smtp email channel")
return &Email{
smtpClient: client,
senderName: cfg.FromName,
senderAddress: cfg.From,
replyToAddress: cfg.ReplyToAddress,
}, nil
}
func (email *Email) HandleMessage(message channels.Message) error {
defer email.smtpClient.Close()
emailMsg, ok := message.(*messages.Email)
if !ok {
return zerrors.ThrowInternal(nil, "EMAIL-s8JLs", "Errors.SMTP.NotEmailMessage")
}
if emailMsg.Content == "" || emailMsg.Subject == "" || len(emailMsg.Recipients) == 0 {
return zerrors.ThrowInternal(nil, "EMAIL-zGemZ", "Errors.SMTP.RequiredAttributes")
}
emailMsg.SenderEmail = email.senderAddress
emailMsg.SenderName = email.senderName
emailMsg.ReplyToAddress = email.replyToAddress
// To && From
if err := email.smtpClient.Mail(emailMsg.SenderEmail); err != nil {
return zerrors.ThrowInternal(err, "EMAIL-s3is3", "Errors.SMTP.CouldNotSetSender")
}
for _, recp := range append(append(emailMsg.Recipients, emailMsg.CC...), emailMsg.BCC...) {
if err := email.smtpClient.Rcpt(recp); err != nil {
return zerrors.ThrowInternal(err, "EMAIL-s4is4", "Errors.SMTP.CouldNotSetRecipient")
}
}
// Data
w, err := email.smtpClient.Data()
if err != nil {
return err
}
content, err := emailMsg.GetContent()
if err != nil {
return err
}
_, err = w.Write([]byte(content))
if err != nil {
return err
}
err = w.Close()
if err != nil {
return err
}
return email.smtpClient.Quit()
}
func (smtpConfig SMTP) connectToSMTP(tlsRequired bool) (client *smtp.Client, err error) {
host, _, err := net.SplitHostPort(smtpConfig.Host)
if err != nil {
return nil, zerrors.ThrowInternal(err, "EMAIL-spR56", "Errors.SMTP.CouldNotSplit")
}
if !tlsRequired {
client, err = smtpConfig.getSMTPClient()
} else {
client, err = smtpConfig.getSMTPClientWithTls(host)
}
if err != nil {
return nil, err
}
err = smtpConfig.smtpAuth(client, host)
if err != nil {
return nil, err
}
return client, nil
}
func (smtpConfig SMTP) getSMTPClient() (*smtp.Client, error) {
client, err := smtp.Dial(smtpConfig.Host)
if err != nil {
return nil, zerrors.ThrowInternal(err, "EMAIL-skwos", "Errors.SMTP.CouldNotDial")
}
return client, nil
}
func (smtpConfig SMTP) getSMTPClientWithTls(host string) (*smtp.Client, error) {
conn, err := tls.Dial("tcp", smtpConfig.Host, &tls.Config{})
if errors.As(err, &tls.RecordHeaderError{}) {
logging.OnError(err).Warn("could not connect using normal tls. trying starttls instead...")
return smtpConfig.getSMTPClientWithStartTls(host)
}
if err != nil {
return nil, zerrors.ThrowInternal(err, "EMAIL-sl39s", "Errors.SMTP.CouldNotDialTLS")
}
client, err := smtp.NewClient(conn, host)
if err != nil {
return nil, zerrors.ThrowInternal(err, "EMAIL-skwi4", "Errors.SMTP.CouldNotCreateClient")
}
return client, err
}
func (smtpConfig SMTP) getSMTPClientWithStartTls(host string) (*smtp.Client, error) {
client, err := smtpConfig.getSMTPClient()
if err != nil {
return nil, err
}
if err := client.StartTLS(&tls.Config{
ServerName: host,
}); err != nil {
return nil, zerrors.ThrowInternal(err, "EMAIL-guvsQ", "Errors.SMTP.CouldNotStartTLS")
}
return client, nil
}
func (smtpConfig SMTP) smtpAuth(client *smtp.Client, host string) error {
if !smtpConfig.HasAuth() {
return nil
}
// Auth
err := client.Auth(PlainOrLoginAuth(smtpConfig.User, smtpConfig.Password, host))
if err != nil {
return zerrors.ThrowInternal(err, "EMAIL-s9kfs", "Errors.SMTP.CouldNotAuth")
}
return nil
}
func TestConfiguration(cfg *Config, testEmail string) error {
client, err := cfg.SMTP.connectToSMTP(cfg.Tls)
if err != nil {
return err
}
defer client.Close()
message := &messages.Email{
Recipients: []string{testEmail},
Subject: "Test email",
Content: "This is a test email to check if your SMTP provider works fine",
SenderEmail: cfg.From,
SenderName: cfg.FromName,
}
if err := client.Mail(cfg.From); err != nil {
return zerrors.ThrowInternal(err, "EMAIL-s3is3", "Errors.SMTP.CouldNotSetSender")
}
if err := client.Rcpt(testEmail); err != nil {
return zerrors.ThrowInternal(err, "EMAIL-s4is4", "Errors.SMTP.CouldNotSetRecipient")
}
// Open data connection
w, err := client.Data()
if err != nil {
return err
}
// Send content
content, err := message.GetContent()
if err != nil {
return err
}
_, err = w.Write([]byte(content))
if err != nil {
return err
}
// Close IO and quit smtp connection
err = w.Close()
if err != nil {
return err
}
return client.Quit()
}

View File

@@ -0,0 +1,23 @@
package smtp
type Config struct {
SMTP SMTP
Tls bool
From string
FromName string
ReplyToAddress string
}
type SMTP struct {
Host string
User string
Password string
}
func (smtp *SMTP) HasAuth() bool {
return smtp.User != "" && smtp.Password != ""
}
type ConfigHTTP struct {
Endpoint string
}

View File

@@ -0,0 +1,57 @@
package smtp
import (
"bytes"
"net/smtp"
"slices"
"github.com/zitadel/zitadel/internal/zerrors"
)
// golang net/smtp SMTP AUTH LOGIN or PLAIN Auth Handler
// Reference: https://gist.github.com/andelf/5118732?permalink_comment_id=4825669#gistcomment-4825669
func PlainOrLoginAuth(username, password, host string) smtp.Auth {
return &plainOrLoginAuth{username: username, password: password, host: host}
}
type plainOrLoginAuth struct {
username string
password string
host string
authMethod string
}
func (a *plainOrLoginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
if server.Name != a.host {
return "", nil, zerrors.ThrowInternal(nil, "SMTP-RRi75", "wrong host name")
}
if !slices.Contains(server.Auth, "PLAIN") {
a.authMethod = "LOGIN"
return a.authMethod, nil, nil
} else {
a.authMethod = "PLAIN"
resp := []byte("\x00" + a.username + "\x00" + a.password)
return a.authMethod, resp, nil
}
}
func (a *plainOrLoginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
if !more {
return nil, nil
}
if a.authMethod == "PLAIN" {
// We've already sent everything.
return nil, zerrors.ThrowInternal(nil, "SMTP-AAf43", "unexpected server challenge for PLAIN auth method")
}
switch {
case bytes.Equal(fromServer, []byte("Username:")):
return []byte(a.username), nil
case bytes.Equal(fromServer, []byte("Password:")):
return []byte(a.password), nil
default:
return nil, zerrors.ThrowInternal(nil, "SMTP-HjW21", "unexpected server challenge")
}
}

View File

@@ -0,0 +1,76 @@
package twilio
import (
"errors"
"github.com/twilio/twilio-go"
twilioClient "github.com/twilio/twilio-go/client"
openapi "github.com/twilio/twilio-go/rest/api/v2010"
verify "github.com/twilio/twilio-go/rest/verify/v2"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/notification/channels"
"github.com/zitadel/zitadel/internal/notification/messages"
"github.com/zitadel/zitadel/internal/zerrors"
)
const (
aggregateTypeNotification = "notification"
)
func InitChannel(config Config) channels.NotificationChannel {
client := twilio.NewRestClientWithParams(twilio.ClientParams{Username: config.SID, Password: config.Token})
logging.Debug("successfully initialized twilio sms channel")
return channels.HandleMessageFunc(func(message channels.Message) error {
twilioMsg, ok := message.(*messages.SMS)
if !ok {
return zerrors.ThrowInternal(nil, "TWILI-s0pLc", "message is not SMS")
}
if config.VerifyServiceSID != "" {
params := &verify.CreateVerificationParams{}
params.SetTo(twilioMsg.RecipientPhoneNumber)
params.SetChannel("sms")
resp, err := client.VerifyV2.CreateVerification(config.VerifyServiceSID, params)
// In case of any client error (4xx), we should not retry sending the verification code
// as it would be a waste of resources and could potentially result in a rate limit.
var twilioErr *twilioClient.TwilioRestError
if errors.As(err, &twilioErr) && twilioErr.Status >= 400 && twilioErr.Status < 500 {
logging.WithFields(
"error", twilioErr.Message,
"status", twilioErr.Status,
"code", twilioErr.Code,
"instanceID", twilioMsg.InstanceID,
"jobID", twilioMsg.JobID,
"userID", twilioMsg.UserID,
).Warn("twilio create verification error")
return channels.NewCancelError(twilioErr)
}
if err != nil {
return zerrors.ThrowInternal(err, "TWILI-0s9f2", "could not send verification")
}
logging.WithFields("sid", resp.Sid, "status", resp.Status).Debug("verification sent")
twilioMsg.VerificationID = resp.Sid
return nil
}
content, err := twilioMsg.GetContent()
if err != nil {
return err
}
params := &openapi.CreateMessageParams{}
params.SetTo(twilioMsg.RecipientPhoneNumber)
params.SetFrom(twilioMsg.SenderPhoneNumber)
params.SetBody(content)
m, err := client.Api.CreateMessage(params)
if err != nil {
return zerrors.ThrowInternal(err, "TWILI-osk3S", "could not send message")
}
logging.WithFields("message_sid", m.Sid, "status", m.Status).Debug("sms sent")
return nil
})
}

View File

@@ -0,0 +1,40 @@
package twilio
import (
newTwilio "github.com/twilio/twilio-go"
verify "github.com/twilio/twilio-go/rest/verify/v2"
"github.com/zitadel/zitadel/internal/zerrors"
)
type Config struct {
SID string
Token string
SenderNumber string
VerifyServiceSID string
}
func (t *Config) IsValid() bool {
return t.SID != "" && t.Token != "" && t.SenderNumber != ""
}
func (t *Config) VerifyCode(verificationID, code string) error {
client := newTwilio.NewRestClientWithParams(newTwilio.ClientParams{Username: t.SID, Password: t.Token})
checkParams := &verify.CreateVerificationCheckParams{}
checkParams.SetVerificationSid(verificationID)
checkParams.SetCode(code)
resp, err := client.VerifyV2.CreateVerificationCheck(t.VerifyServiceSID, checkParams)
if err != nil || resp.Status == nil {
return zerrors.ThrowInvalidArgument(err, "TWILI-JK3ta", "Errors.User.Code.NotFound")
}
switch *resp.Status {
case "approved":
return nil
case "expired":
return zerrors.ThrowInvalidArgument(nil, "TWILI-SF3ba", "Errors.User.Code.Expired")
case "max_attempts_reached":
return zerrors.ThrowInvalidArgument(nil, "TWILI-Ok39a", "Errors.User.Code.NotFound")
default:
return zerrors.ThrowInvalidArgument(nil, "TWILI-Skwe4", "Errors.User.Code.Invalid")
}
}

View File

@@ -0,0 +1,55 @@
package webhook
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/notification/channels"
"github.com/zitadel/zitadel/internal/notification/messages"
"github.com/zitadel/zitadel/internal/zerrors"
)
func InitChannel(ctx context.Context, cfg Config) (channels.NotificationChannel, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
logging.Debug("successfully initialized webhook json channel")
return channels.HandleMessageFunc(func(message channels.Message) error {
requestCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
msg, ok := message.(*messages.JSON)
if !ok {
return zerrors.ThrowInternal(nil, "WEBH-K686U", "message is not JSON")
}
payload, err := msg.GetContent()
if err != nil {
return err
}
req, err := http.NewRequestWithContext(requestCtx, cfg.Method, cfg.CallURL, strings.NewReader(payload))
if err != nil {
return err
}
if cfg.Headers != nil {
req.Header = cfg.Headers
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
if err = resp.Body.Close(); err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return zerrors.ThrowUnknown(fmt.Errorf("calling url %s returned %s", cfg.CallURL, resp.Status), "WEBH-LBxU0", "webhook didn't return a success status")
}
logging.WithFields("calling_url", cfg.CallURL, "method", cfg.Method).Debug("webhook called")
return nil
}), nil
}

View File

@@ -0,0 +1,17 @@
package webhook
import (
"net/http"
"net/url"
)
type Config struct {
CallURL string
Method string
Headers http.Header
}
func (w *Config) Validate() error {
_, err := url.Parse(w.CallURL)
return err
}

View File

@@ -0,0 +1,50 @@
package handlers
import (
"context"
"github.com/zitadel/zitadel/internal/eventstore"
)
type alreadyHandled struct {
event eventstore.Event
eventTypes []eventstore.EventType
data map[string]interface{}
handled bool
}
func (a *alreadyHandled) Reduce() error {
return nil
}
func (a *alreadyHandled) AppendEvents(event ...eventstore.Event) {
if len(event) > 0 {
a.handled = true
}
}
func (a *alreadyHandled) Query() *eventstore.SearchQueryBuilder {
return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
InstanceID(a.event.Aggregate().InstanceID).
SequenceGreater(a.event.Sequence()).
AddQuery().
AggregateTypes(a.event.Aggregate().Type).
AggregateIDs(a.event.Aggregate().ID).
EventTypes(a.eventTypes...).
EventData(a.data).
Builder()
}
func (n *NotificationQueries) IsAlreadyHandled(ctx context.Context, event eventstore.Event, data map[string]interface{}, eventTypes ...eventstore.EventType) (bool, error) {
already := &alreadyHandled{
event: event,
eventTypes: eventTypes,
data: data,
}
err := n.es.FilterToQueryReducer(ctx, already)
if err != nil {
return false, err
}
return already.handled, nil
}

View File

@@ -0,0 +1,250 @@
package handlers
import (
"context"
"errors"
"slices"
"sync"
"time"
"github.com/zitadel/oidc/v3/pkg/crypto"
"github.com/zitadel/oidc/v3/pkg/oidc"
"github.com/zitadel/zitadel/internal/api/authz"
http_utils "github.com/zitadel/zitadel/internal/api/http"
zoidc "github.com/zitadel/zitadel/internal/api/oidc"
"github.com/zitadel/zitadel/internal/command"
zcrypto "github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/handler/v2"
"github.com/zitadel/zitadel/internal/id"
"github.com/zitadel/zitadel/internal/notification/channels/set"
_ "github.com/zitadel/zitadel/internal/notification/statik"
"github.com/zitadel/zitadel/internal/notification/types"
"github.com/zitadel/zitadel/internal/repository/session"
"github.com/zitadel/zitadel/internal/repository/sessionlogout"
"github.com/zitadel/zitadel/internal/repository/user"
"github.com/zitadel/zitadel/internal/zerrors"
)
const (
BackChannelLogoutNotificationsProjectionTable = "projections.notifications_back_channel_logout"
)
type backChannelLogoutNotifier struct {
commands *command.Commands
queries *NotificationQueries
eventstore *eventstore.Eventstore
keyEncryptionAlg zcrypto.EncryptionAlgorithm
channels types.ChannelChains
idGenerator id.Generator
tokenLifetime time.Duration
}
func NewBackChannelLogoutNotifier(
ctx context.Context,
config handler.Config,
commands *command.Commands,
queries *NotificationQueries,
es *eventstore.Eventstore,
keyEncryptionAlg zcrypto.EncryptionAlgorithm,
channels types.ChannelChains,
tokenLifetime time.Duration,
) *handler.Handler {
return handler.NewHandler(ctx, &config, &backChannelLogoutNotifier{
commands: commands,
queries: queries,
eventstore: es,
keyEncryptionAlg: keyEncryptionAlg,
channels: channels,
tokenLifetime: tokenLifetime,
idGenerator: id.SonyFlakeGenerator(),
})
}
func (*backChannelLogoutNotifier) Name() string {
return BackChannelLogoutNotificationsProjectionTable
}
func (u *backChannelLogoutNotifier) Reducers() []handler.AggregateReducer {
return []handler.AggregateReducer{
{
Aggregate: session.AggregateType,
EventReducers: []handler.EventReducer{
{
Event: session.TerminateType,
Reduce: u.reduceSessionTerminated,
},
},
}, {
Aggregate: user.AggregateType,
EventReducers: []handler.EventReducer{
{
Event: user.HumanSignedOutType,
Reduce: u.reduceUserSignedOut,
},
},
},
}
}
func (u *backChannelLogoutNotifier) reduceUserSignedOut(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*user.HumanSignedOutEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Gr63h", "reduce.wrong.event.type %s", user.HumanSignedOutType)
}
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
ctx, err := u.queries.HandlerContext(ctx, event.Aggregate())
if err != nil {
return err
}
if !authz.GetFeatures(ctx).EnableBackChannelLogout {
return nil
}
if e.SessionID == "" {
return nil
}
return u.terminateSession(ctx, e.SessionID, e)
}), nil
}
func (u *backChannelLogoutNotifier) reduceSessionTerminated(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*session.TerminateEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-D6H2h", "reduce.wrong.event.type %s", session.TerminateType)
}
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
ctx, err := u.queries.HandlerContext(ctx, event.Aggregate())
if err != nil {
return err
}
if !authz.GetFeatures(ctx).EnableBackChannelLogout {
return nil
}
return u.terminateSession(ctx, e.Aggregate().ID, e)
}), nil
}
type backChannelLogoutSession struct {
sessionID string
// sessions contain a map of oidc session IDs and their corresponding clientID
sessions []backChannelLogoutOIDCSessions
}
func (u *backChannelLogoutNotifier) terminateSession(ctx context.Context, id string, e eventstore.Event) error {
sessions := &backChannelLogoutSession{sessionID: id}
err := u.eventstore.FilterToQueryReducer(ctx, sessions)
if err != nil {
return err
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
getSigner := zoidc.GetSignerOnce(u.queries.GetActiveSigningWebKey)
var wg sync.WaitGroup
wg.Add(len(sessions.sessions))
errs := make([]error, 0, len(sessions.sessions))
for _, oidcSession := range sessions.sessions {
go func(oidcSession *backChannelLogoutOIDCSessions) {
defer wg.Done()
err := u.sendLogoutToken(ctx, oidcSession, e, getSigner)
if err != nil {
errs = append(errs, err)
return
}
err = u.commands.BackChannelLogoutSent(ctx, oidcSession.SessionID, oidcSession.OIDCSessionID, e.Aggregate().InstanceID)
if err != nil {
errs = append(errs, err)
}
}(&oidcSession)
}
wg.Wait()
return errors.Join(errs...)
}
func (u *backChannelLogoutNotifier) sendLogoutToken(ctx context.Context, oidcSession *backChannelLogoutOIDCSessions, e eventstore.Event, getSigner zoidc.SignerFunc) error {
token, err := u.logoutToken(ctx, oidcSession, getSigner)
if err != nil {
return err
}
err = types.SendSecurityTokenEvent(ctx, set.Config{CallURL: oidcSession.BackChannelLogoutURI}, u.channels, &LogoutTokenMessage{LogoutToken: token}, e.Type()).WithoutTemplate()
if err != nil {
return err
}
return nil
}
func (u *backChannelLogoutNotifier) logoutToken(ctx context.Context, oidcSession *backChannelLogoutOIDCSessions, getSigner zoidc.SignerFunc) (string, error) {
jwtID, err := u.idGenerator.Next()
if err != nil {
return "", err
}
token := oidc.NewLogoutTokenClaims(
http_utils.DomainContext(ctx).Origin(),
oidcSession.UserID,
oidc.Audience{oidcSession.ClientID},
time.Now().Add(u.tokenLifetime),
jwtID,
oidcSession.SessionID,
time.Second,
)
signer, _, err := getSigner(ctx)
if err != nil {
return "", err
}
return crypto.Sign(token, signer)
}
type LogoutTokenMessage struct {
LogoutToken string `schema:"logout_token"`
}
type backChannelLogoutOIDCSessions struct {
SessionID string
OIDCSessionID string
UserID string
ClientID string
BackChannelLogoutURI string
}
func (b *backChannelLogoutSession) Reduce() error {
return nil
}
func (b *backChannelLogoutSession) AppendEvents(events ...eventstore.Event) {
for _, event := range events {
switch e := event.(type) {
case *sessionlogout.BackChannelLogoutRegisteredEvent:
b.sessions = append(b.sessions, backChannelLogoutOIDCSessions{
SessionID: b.sessionID,
OIDCSessionID: e.OIDCSessionID,
UserID: e.UserID,
ClientID: e.ClientID,
BackChannelLogoutURI: e.BackChannelLogoutURI,
})
case *sessionlogout.BackChannelLogoutSentEvent:
b.sessions = slices.DeleteFunc(b.sessions, func(session backChannelLogoutOIDCSessions) bool {
return session.OIDCSessionID == e.OIDCSessionID
})
}
}
}
func (b *backChannelLogoutSession) Query() *eventstore.SearchQueryBuilder {
return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
AddQuery().
AggregateTypes(sessionlogout.AggregateType).
AggregateIDs(b.sessionID).
EventTypes(
sessionlogout.BackChannelLogoutRegisteredType,
sessionlogout.BackChannelLogoutSentType).
Builder()
}

View File

@@ -0,0 +1,26 @@
package handlers
import (
"context"
"github.com/zitadel/zitadel/internal/notification/senders"
"github.com/zitadel/zitadel/internal/repository/milestone"
"github.com/zitadel/zitadel/internal/repository/quota"
)
type Commands interface {
HumanInitCodeSent(ctx context.Context, orgID, userID string) error
HumanEmailVerificationCodeSent(ctx context.Context, orgID, userID string) error
PasswordCodeSent(ctx context.Context, orgID, userID string, generatorInfo *senders.CodeGeneratorInfo) error
HumanOTPSMSCodeSent(ctx context.Context, userID, resourceOwner string, generatorInfo *senders.CodeGeneratorInfo) error
HumanOTPEmailCodeSent(ctx context.Context, userID, resourceOwner string) error
OTPSMSSent(ctx context.Context, sessionID, resourceOwner string, generatorInfo *senders.CodeGeneratorInfo) error
OTPEmailSent(ctx context.Context, sessionID, resourceOwner string) error
UserDomainClaimedSent(ctx context.Context, orgID, userID string) error
HumanPasswordlessInitCodeSent(ctx context.Context, userID, resourceOwner, codeID string) error
PasswordChangeSent(ctx context.Context, orgID, userID string) error
HumanPhoneVerificationCodeSent(ctx context.Context, orgID, userID string, generatorInfo *senders.CodeGeneratorInfo) error
InviteCodeSent(ctx context.Context, orgID, userID string) error
UsageNotificationSent(ctx context.Context, dueEvent *quota.NotificationDueEvent) error
MilestonePushed(ctx context.Context, instanceID string, msType milestone.Type, endpoints []string) error
}

View File

@@ -0,0 +1,59 @@
package handlers
import (
"context"
"net/http"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/notification/channels/email"
"github.com/zitadel/zitadel/internal/notification/channels/smtp"
"github.com/zitadel/zitadel/internal/notification/channels/webhook"
"github.com/zitadel/zitadel/internal/zerrors"
)
// GetSMTPConfig reads the iam SMTP provider config
func (n *NotificationQueries) GetActiveEmailConfig(ctx context.Context) (*email.Config, error) {
config, err := n.SMTPConfigActive(ctx, authz.GetInstance(ctx).InstanceID())
if err != nil {
return nil, err
}
provider := &email.Provider{
ID: config.ID,
Description: config.Description,
}
if config.SMTPConfig != nil {
if config.SMTPConfig.Password == nil {
return nil, zerrors.ThrowNotFound(err, "QUERY-Wrs3gw", "Errors.SMTPConfig.NotFound")
}
password, err := crypto.DecryptString(config.SMTPConfig.Password, n.SMTPPasswordCrypto)
if err != nil {
return nil, err
}
return &email.Config{
ProviderConfig: provider,
SMTPConfig: &smtp.Config{
From: config.SMTPConfig.SenderAddress,
FromName: config.SMTPConfig.SenderName,
ReplyToAddress: config.SMTPConfig.ReplyToAddress,
Tls: config.SMTPConfig.TLS,
SMTP: smtp.SMTP{
Host: config.SMTPConfig.Host,
User: config.SMTPConfig.User,
Password: password,
},
},
}, nil
}
if config.HTTPConfig != nil {
return &email.Config{
ProviderConfig: provider,
WebhookConfig: &webhook.Config{
CallURL: config.HTTPConfig.Endpoint,
Method: http.MethodPost,
Headers: nil,
},
}, nil
}
return nil, zerrors.ThrowNotFound(err, "QUERY-KPQleOckOV", "Errors.SMTPConfig.NotFound")
}

View File

@@ -0,0 +1,21 @@
package handlers
import (
"context"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/notification/channels/fs"
)
// GetFileSystemProvider reads the iam filesystem provider config
func (n *NotificationQueries) GetFileSystemProvider(ctx context.Context) (*fs.Config, error) {
config, err := n.NotificationProviderByIDAndType(ctx, authz.GetInstance(ctx).InstanceID(), domain.NotificationProviderTypeFile)
if err != nil {
return nil, err
}
return &fs.Config{
Compact: config.Compact,
Path: n.fileSystemPath,
}, nil
}

View File

@@ -0,0 +1,20 @@
package handlers
import (
"context"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/notification/channels/log"
)
// GetLogProvider reads the iam log provider config
func (n *NotificationQueries) GetLogProvider(ctx context.Context) (*log.Config, error) {
config, err := n.NotificationProviderByIDAndType(ctx, authz.GetInstance(ctx).InstanceID(), domain.NotificationProviderTypeLog)
if err != nil {
return nil, err
}
return &log.Config{
Compact: config.Compact,
}, nil
}

View File

@@ -0,0 +1,56 @@
package handlers
import (
"context"
"net/http"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/notification/channels/sms"
"github.com/zitadel/zitadel/internal/notification/channels/twilio"
"github.com/zitadel/zitadel/internal/notification/channels/webhook"
"github.com/zitadel/zitadel/internal/zerrors"
)
// GetActiveSMSConfig reads the active iam sms provider config
func (n *NotificationQueries) GetActiveSMSConfig(ctx context.Context) (*sms.Config, error) {
config, err := n.SMSProviderConfigActive(ctx, authz.GetInstance(ctx).InstanceID())
if err != nil {
return nil, err
}
provider := &sms.Provider{
ID: config.ID,
Description: config.Description,
}
if config.TwilioConfig != nil {
if config.TwilioConfig.Token == nil {
return nil, zerrors.ThrowNotFound(err, "QUERY-SFefsd", "Errors.SMS.Twilio.NotFound")
}
token, err := crypto.DecryptString(config.TwilioConfig.Token, n.SMSTokenCrypto)
if err != nil {
return nil, err
}
return &sms.Config{
ProviderConfig: provider,
TwilioConfig: &twilio.Config{
SID: config.TwilioConfig.SID,
Token: token,
SenderNumber: config.TwilioConfig.SenderNumber,
VerifyServiceSID: config.TwilioConfig.VerifyServiceSID,
},
}, nil
}
if config.HTTPConfig != nil {
return &sms.Config{
ProviderConfig: provider,
WebhookConfig: &webhook.Config{
CallURL: config.HTTPConfig.Endpoint,
Method: http.MethodPost,
Headers: nil,
},
}, nil
}
return nil, zerrors.ThrowNotFound(nil, "HANDLER-8nfow", "Errors.SMS.Twilio.NotFound")
}

View File

@@ -0,0 +1,28 @@
package handlers
import (
"context"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/eventstore"
)
const NotifyUserID = "NOTIFICATION" //TODO: system?
func HandlerContext(parent context.Context, event *eventstore.Aggregate) context.Context {
ctx := authz.WithInstanceID(parent, event.InstanceID)
return authz.SetCtxData(ctx, authz.CtxData{UserID: NotifyUserID, OrgID: event.ResourceOwner})
}
func ContextWithNotifier(ctx context.Context, aggregate *eventstore.Aggregate) context.Context {
return authz.WithInstanceID(authz.SetCtxData(ctx, authz.CtxData{UserID: NotifyUserID, OrgID: aggregate.ResourceOwner}), aggregate.InstanceID)
}
func (n *NotificationQueries) HandlerContext(parent context.Context, event *eventstore.Aggregate) (context.Context, error) {
instance, err := n.InstanceByID(parent, event.InstanceID)
if err != nil {
return nil, err
}
ctx := authz.WithInstance(parent, instance)
return authz.SetCtxData(ctx, authz.CtxData{UserID: NotifyUserID, OrgID: event.ResourceOwner}), nil
}

View File

@@ -0,0 +1,5 @@
package handlers
//go:generate mockgen -package mock -destination ./mock/queries.mock.go github.com/zitadel/zitadel/internal/notification/handlers Queries
//go:generate mockgen -package mock -destination ./mock/commands.mock.go github.com/zitadel/zitadel/internal/notification/handlers Commands
//go:generate mockgen -package mock -destination ./mock/queue.mock.go github.com/zitadel/zitadel/internal/notification/handlers Queue

View File

@@ -0,0 +1,23 @@
//go:build integration
package handlers_test
import (
"context"
"os"
"testing"
"time"
)
var (
CTX context.Context
)
func TestMain(m *testing.M) {
os.Exit(func() int {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
CTX = ctx
return m.Run()
}())
}

View File

@@ -0,0 +1,159 @@
//go:build integration
package handlers_test
import (
"bytes"
"encoding/json"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/oidc/v3/pkg/client/rp"
"github.com/zitadel/oidc/v3/pkg/oidc"
"github.com/zitadel/zitadel/internal/integration"
"github.com/zitadel/zitadel/internal/integration/sink"
"github.com/zitadel/zitadel/internal/repository/milestone"
"github.com/zitadel/zitadel/pkg/grpc/app"
"github.com/zitadel/zitadel/pkg/grpc/management"
"github.com/zitadel/zitadel/pkg/grpc/object"
oidc_v2 "github.com/zitadel/zitadel/pkg/grpc/oidc/v2"
"github.com/zitadel/zitadel/pkg/grpc/project"
"github.com/zitadel/zitadel/pkg/grpc/system"
)
func TestServer_TelemetryPushMilestones(t *testing.T) {
sub := sink.Subscribe(CTX, sink.ChannelMilestone)
defer sub.Close()
instance := integration.NewInstance(CTX)
iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner)
t.Log("testing against instance", instance.ID())
awaitMilestone(t, sub, instance.ID(), milestone.InstanceCreated)
projectAdded, err := instance.Client.Mgmt.AddProject(iamOwnerCtx, &management.AddProjectRequest{Name: "integration"})
require.NoError(t, err)
awaitMilestone(t, sub, instance.ID(), milestone.ProjectCreated)
redirectURI := "http://localhost:8888"
application, err := instance.Client.Mgmt.AddOIDCApp(iamOwnerCtx, &management.AddOIDCAppRequest{
ProjectId: projectAdded.GetId(),
Name: "integration",
RedirectUris: []string{redirectURI},
ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE},
GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE},
AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB,
AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE,
DevMode: true,
AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT,
})
require.NoError(t, err)
awaitMilestone(t, sub, instance.ID(), milestone.ApplicationCreated)
// create the session to be used for the authN of the clients
sessionID, sessionToken, _, _ := instance.CreatePasswordSession(t, iamOwnerCtx, instance.AdminUserID, "Password1!")
console := consoleOIDCConfig(t, instance)
loginToClient(t, instance, console.GetClientId(), console.GetRedirectUris()[0], sessionID, sessionToken)
awaitMilestone(t, sub, instance.ID(), milestone.AuthenticationSucceededOnInstance)
// make sure the client has been projected
require.EventuallyWithT(t, func(collectT *assert.CollectT) {
_, err := instance.Client.Mgmt.GetAppByID(iamOwnerCtx, &management.GetAppByIDRequest{
ProjectId: projectAdded.GetId(),
AppId: application.GetAppId(),
})
assert.NoError(collectT, err)
}, time.Minute, time.Second, "app not found")
loginToClient(t, instance, application.GetClientId(), redirectURI, sessionID, sessionToken)
awaitMilestone(t, sub, instance.ID(), milestone.AuthenticationSucceededOnApplication)
_, err = integration.SystemClient().RemoveInstance(CTX, &system.RemoveInstanceRequest{InstanceId: instance.ID()})
require.NoError(t, err)
awaitMilestone(t, sub, instance.ID(), milestone.InstanceDeleted)
}
func loginToClient(t *testing.T, instance *integration.Instance, clientID, redirectURI, sessionID, sessionToken string) {
iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner)
_, authRequestID, err := instance.CreateOIDCAuthRequestWithDomain(iamOwnerCtx, instance.Domain, clientID, instance.Users.Get(integration.UserTypeIAMOwner).ID, redirectURI, "openid")
require.NoError(t, err)
callback, err := instance.Client.OIDCv2.CreateCallback(iamOwnerCtx, &oidc_v2.CreateCallbackRequest{
AuthRequestId: authRequestID,
CallbackKind: &oidc_v2.CreateCallbackRequest_Session{Session: &oidc_v2.Session{
SessionId: sessionID,
SessionToken: sessionToken,
}},
})
require.NoError(t, err)
provider, err := instance.CreateRelyingPartyForDomain(iamOwnerCtx, instance.Domain, clientID, redirectURI, instance.Users.Get(integration.UserTypeLogin).Username)
require.NoError(t, err)
callbackURL, err := url.Parse(callback.GetCallbackUrl())
require.NoError(t, err)
code := callbackURL.Query().Get("code")
_, err = rp.CodeExchange[*oidc.IDTokenClaims](iamOwnerCtx, code, provider, rp.WithCodeVerifier(integration.CodeVerifier))
require.NoError(t, err)
}
func consoleOIDCConfig(t *testing.T, instance *integration.Instance) *app.OIDCConfig {
iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner)
projects, err := instance.Client.Mgmt.ListProjects(iamOwnerCtx, &management.ListProjectsRequest{
Queries: []*project.ProjectQuery{
{
Query: &project.ProjectQuery_NameQuery{
NameQuery: &project.ProjectNameQuery{
Name: "ZITADEL",
Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS,
},
},
},
},
})
require.NoError(t, err)
require.Len(t, projects.GetResult(), 1)
apps, err := instance.Client.Mgmt.ListApps(iamOwnerCtx, &management.ListAppsRequest{
ProjectId: projects.GetResult()[0].GetId(),
Queries: []*app.AppQuery{
{
Query: &app.AppQuery_NameQuery{
NameQuery: &app.AppNameQuery{
Name: "Console",
Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS,
},
},
},
},
})
require.NoError(t, err)
require.Len(t, apps.GetResult(), 1)
return apps.GetResult()[0].GetOidcConfig()
}
func awaitMilestone(t *testing.T, sub *sink.Subscription, instanceID string, expectMilestoneType milestone.Type) {
for {
select {
case req := <-sub.Recv():
plain := new(bytes.Buffer)
if err := json.Indent(plain, req.Body, "", " "); err != nil {
t.Fatal(err)
}
t.Log("received milestone", plain.String())
milestone := struct {
InstanceID string `json:"instanceId"`
Type milestone.Type `json:"type"`
}{}
if err := json.Unmarshal(req.Body, &milestone); err != nil {
t.Error(err)
}
if milestone.Type == expectMilestoneType && milestone.InstanceID == instanceID {
return
}
case <-time.After(20 * time.Second):
t.Fatalf("timed out waiting for milestone %s for instance %s", expectMilestoneType, instanceID)
}
}
}

View File

@@ -0,0 +1,240 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/zitadel/zitadel/internal/notification/handlers (interfaces: Commands)
//
// Generated by this command:
//
// mockgen -package mock -destination ./mock/commands.mock.go github.com/zitadel/zitadel/internal/notification/handlers Commands
//
// Package mock is a generated GoMock package.
package mock
import (
context "context"
reflect "reflect"
senders "github.com/zitadel/zitadel/internal/notification/senders"
milestone "github.com/zitadel/zitadel/internal/repository/milestone"
quota "github.com/zitadel/zitadel/internal/repository/quota"
gomock "go.uber.org/mock/gomock"
)
// MockCommands is a mock of Commands interface.
type MockCommands struct {
ctrl *gomock.Controller
recorder *MockCommandsMockRecorder
isgomock struct{}
}
// MockCommandsMockRecorder is the mock recorder for MockCommands.
type MockCommandsMockRecorder struct {
mock *MockCommands
}
// NewMockCommands creates a new mock instance.
func NewMockCommands(ctrl *gomock.Controller) *MockCommands {
mock := &MockCommands{ctrl: ctrl}
mock.recorder = &MockCommandsMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockCommands) EXPECT() *MockCommandsMockRecorder {
return m.recorder
}
// HumanEmailVerificationCodeSent mocks base method.
func (m *MockCommands) HumanEmailVerificationCodeSent(ctx context.Context, orgID, userID string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "HumanEmailVerificationCodeSent", ctx, orgID, userID)
ret0, _ := ret[0].(error)
return ret0
}
// HumanEmailVerificationCodeSent indicates an expected call of HumanEmailVerificationCodeSent.
func (mr *MockCommandsMockRecorder) HumanEmailVerificationCodeSent(ctx, orgID, userID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanEmailVerificationCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanEmailVerificationCodeSent), ctx, orgID, userID)
}
// HumanInitCodeSent mocks base method.
func (m *MockCommands) HumanInitCodeSent(ctx context.Context, orgID, userID string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "HumanInitCodeSent", ctx, orgID, userID)
ret0, _ := ret[0].(error)
return ret0
}
// HumanInitCodeSent indicates an expected call of HumanInitCodeSent.
func (mr *MockCommandsMockRecorder) HumanInitCodeSent(ctx, orgID, userID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanInitCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanInitCodeSent), ctx, orgID, userID)
}
// HumanOTPEmailCodeSent mocks base method.
func (m *MockCommands) HumanOTPEmailCodeSent(ctx context.Context, userID, resourceOwner string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "HumanOTPEmailCodeSent", ctx, userID, resourceOwner)
ret0, _ := ret[0].(error)
return ret0
}
// HumanOTPEmailCodeSent indicates an expected call of HumanOTPEmailCodeSent.
func (mr *MockCommandsMockRecorder) HumanOTPEmailCodeSent(ctx, userID, resourceOwner any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanOTPEmailCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanOTPEmailCodeSent), ctx, userID, resourceOwner)
}
// HumanOTPSMSCodeSent mocks base method.
func (m *MockCommands) HumanOTPSMSCodeSent(ctx context.Context, userID, resourceOwner string, generatorInfo *senders.CodeGeneratorInfo) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "HumanOTPSMSCodeSent", ctx, userID, resourceOwner, generatorInfo)
ret0, _ := ret[0].(error)
return ret0
}
// HumanOTPSMSCodeSent indicates an expected call of HumanOTPSMSCodeSent.
func (mr *MockCommandsMockRecorder) HumanOTPSMSCodeSent(ctx, userID, resourceOwner, generatorInfo any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanOTPSMSCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanOTPSMSCodeSent), ctx, userID, resourceOwner, generatorInfo)
}
// HumanPasswordlessInitCodeSent mocks base method.
func (m *MockCommands) HumanPasswordlessInitCodeSent(ctx context.Context, userID, resourceOwner, codeID string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "HumanPasswordlessInitCodeSent", ctx, userID, resourceOwner, codeID)
ret0, _ := ret[0].(error)
return ret0
}
// HumanPasswordlessInitCodeSent indicates an expected call of HumanPasswordlessInitCodeSent.
func (mr *MockCommandsMockRecorder) HumanPasswordlessInitCodeSent(ctx, userID, resourceOwner, codeID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanPasswordlessInitCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanPasswordlessInitCodeSent), ctx, userID, resourceOwner, codeID)
}
// HumanPhoneVerificationCodeSent mocks base method.
func (m *MockCommands) HumanPhoneVerificationCodeSent(ctx context.Context, orgID, userID string, generatorInfo *senders.CodeGeneratorInfo) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "HumanPhoneVerificationCodeSent", ctx, orgID, userID, generatorInfo)
ret0, _ := ret[0].(error)
return ret0
}
// HumanPhoneVerificationCodeSent indicates an expected call of HumanPhoneVerificationCodeSent.
func (mr *MockCommandsMockRecorder) HumanPhoneVerificationCodeSent(ctx, orgID, userID, generatorInfo any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanPhoneVerificationCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanPhoneVerificationCodeSent), ctx, orgID, userID, generatorInfo)
}
// InviteCodeSent mocks base method.
func (m *MockCommands) InviteCodeSent(ctx context.Context, orgID, userID string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "InviteCodeSent", ctx, orgID, userID)
ret0, _ := ret[0].(error)
return ret0
}
// InviteCodeSent indicates an expected call of InviteCodeSent.
func (mr *MockCommandsMockRecorder) InviteCodeSent(ctx, orgID, userID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InviteCodeSent", reflect.TypeOf((*MockCommands)(nil).InviteCodeSent), ctx, orgID, userID)
}
// MilestonePushed mocks base method.
func (m *MockCommands) MilestonePushed(ctx context.Context, instanceID string, msType milestone.Type, endpoints []string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "MilestonePushed", ctx, instanceID, msType, endpoints)
ret0, _ := ret[0].(error)
return ret0
}
// MilestonePushed indicates an expected call of MilestonePushed.
func (mr *MockCommandsMockRecorder) MilestonePushed(ctx, instanceID, msType, endpoints any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MilestonePushed", reflect.TypeOf((*MockCommands)(nil).MilestonePushed), ctx, instanceID, msType, endpoints)
}
// OTPEmailSent mocks base method.
func (m *MockCommands) OTPEmailSent(ctx context.Context, sessionID, resourceOwner string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "OTPEmailSent", ctx, sessionID, resourceOwner)
ret0, _ := ret[0].(error)
return ret0
}
// OTPEmailSent indicates an expected call of OTPEmailSent.
func (mr *MockCommandsMockRecorder) OTPEmailSent(ctx, sessionID, resourceOwner any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OTPEmailSent", reflect.TypeOf((*MockCommands)(nil).OTPEmailSent), ctx, sessionID, resourceOwner)
}
// OTPSMSSent mocks base method.
func (m *MockCommands) OTPSMSSent(ctx context.Context, sessionID, resourceOwner string, generatorInfo *senders.CodeGeneratorInfo) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "OTPSMSSent", ctx, sessionID, resourceOwner, generatorInfo)
ret0, _ := ret[0].(error)
return ret0
}
// OTPSMSSent indicates an expected call of OTPSMSSent.
func (mr *MockCommandsMockRecorder) OTPSMSSent(ctx, sessionID, resourceOwner, generatorInfo any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OTPSMSSent", reflect.TypeOf((*MockCommands)(nil).OTPSMSSent), ctx, sessionID, resourceOwner, generatorInfo)
}
// PasswordChangeSent mocks base method.
func (m *MockCommands) PasswordChangeSent(ctx context.Context, orgID, userID string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "PasswordChangeSent", ctx, orgID, userID)
ret0, _ := ret[0].(error)
return ret0
}
// PasswordChangeSent indicates an expected call of PasswordChangeSent.
func (mr *MockCommandsMockRecorder) PasswordChangeSent(ctx, orgID, userID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordChangeSent", reflect.TypeOf((*MockCommands)(nil).PasswordChangeSent), ctx, orgID, userID)
}
// PasswordCodeSent mocks base method.
func (m *MockCommands) PasswordCodeSent(ctx context.Context, orgID, userID string, generatorInfo *senders.CodeGeneratorInfo) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "PasswordCodeSent", ctx, orgID, userID, generatorInfo)
ret0, _ := ret[0].(error)
return ret0
}
// PasswordCodeSent indicates an expected call of PasswordCodeSent.
func (mr *MockCommandsMockRecorder) PasswordCodeSent(ctx, orgID, userID, generatorInfo any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordCodeSent", reflect.TypeOf((*MockCommands)(nil).PasswordCodeSent), ctx, orgID, userID, generatorInfo)
}
// UsageNotificationSent mocks base method.
func (m *MockCommands) UsageNotificationSent(ctx context.Context, dueEvent *quota.NotificationDueEvent) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UsageNotificationSent", ctx, dueEvent)
ret0, _ := ret[0].(error)
return ret0
}
// UsageNotificationSent indicates an expected call of UsageNotificationSent.
func (mr *MockCommandsMockRecorder) UsageNotificationSent(ctx, dueEvent any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UsageNotificationSent", reflect.TypeOf((*MockCommands)(nil).UsageNotificationSent), ctx, dueEvent)
}
// UserDomainClaimedSent mocks base method.
func (m *MockCommands) UserDomainClaimedSent(ctx context.Context, orgID, userID string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UserDomainClaimedSent", ctx, orgID, userID)
ret0, _ := ret[0].(error)
return ret0
}
// UserDomainClaimedSent indicates an expected call of UserDomainClaimedSent.
func (mr *MockCommandsMockRecorder) UserDomainClaimedSent(ctx, orgID, userID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserDomainClaimedSent", reflect.TypeOf((*MockCommands)(nil).UserDomainClaimedSent), ctx, orgID, userID)
}

View File

@@ -0,0 +1,284 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/zitadel/zitadel/internal/notification/handlers (interfaces: Queries)
//
// Generated by this command:
//
// mockgen -package mock -destination ./mock/queries.mock.go github.com/zitadel/zitadel/internal/notification/handlers Queries
//
// Package mock is a generated GoMock package.
package mock
import (
context "context"
reflect "reflect"
jose "github.com/go-jose/go-jose/v4"
authz "github.com/zitadel/zitadel/internal/api/authz"
domain "github.com/zitadel/zitadel/internal/domain"
query "github.com/zitadel/zitadel/internal/query"
gomock "go.uber.org/mock/gomock"
language "golang.org/x/text/language"
)
// MockQueries is a mock of Queries interface.
type MockQueries struct {
ctrl *gomock.Controller
recorder *MockQueriesMockRecorder
isgomock struct{}
}
// MockQueriesMockRecorder is the mock recorder for MockQueries.
type MockQueriesMockRecorder struct {
mock *MockQueries
}
// NewMockQueries creates a new mock instance.
func NewMockQueries(ctrl *gomock.Controller) *MockQueries {
mock := &MockQueries{ctrl: ctrl}
mock.recorder = &MockQueriesMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockQueries) EXPECT() *MockQueriesMockRecorder {
return m.recorder
}
// ActiveInstances mocks base method.
func (m *MockQueries) ActiveInstances() []string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ActiveInstances")
ret0, _ := ret[0].([]string)
return ret0
}
// ActiveInstances indicates an expected call of ActiveInstances.
func (mr *MockQueriesMockRecorder) ActiveInstances() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ActiveInstances", reflect.TypeOf((*MockQueries)(nil).ActiveInstances))
}
// ActiveLabelPolicyByOrg mocks base method.
func (m *MockQueries) ActiveLabelPolicyByOrg(ctx context.Context, orgID string, withOwnerRemoved bool) (*query.LabelPolicy, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ActiveLabelPolicyByOrg", ctx, orgID, withOwnerRemoved)
ret0, _ := ret[0].(*query.LabelPolicy)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ActiveLabelPolicyByOrg indicates an expected call of ActiveLabelPolicyByOrg.
func (mr *MockQueriesMockRecorder) ActiveLabelPolicyByOrg(ctx, orgID, withOwnerRemoved any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ActiveLabelPolicyByOrg", reflect.TypeOf((*MockQueries)(nil).ActiveLabelPolicyByOrg), ctx, orgID, withOwnerRemoved)
}
// CustomTextListByTemplate mocks base method.
func (m *MockQueries) CustomTextListByTemplate(ctx context.Context, aggregateID, template string, withOwnerRemoved bool) (*query.CustomTexts, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CustomTextListByTemplate", ctx, aggregateID, template, withOwnerRemoved)
ret0, _ := ret[0].(*query.CustomTexts)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// CustomTextListByTemplate indicates an expected call of CustomTextListByTemplate.
func (mr *MockQueriesMockRecorder) CustomTextListByTemplate(ctx, aggregateID, template, withOwnerRemoved any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CustomTextListByTemplate", reflect.TypeOf((*MockQueries)(nil).CustomTextListByTemplate), ctx, aggregateID, template, withOwnerRemoved)
}
// GetActiveSigningWebKey mocks base method.
func (m *MockQueries) GetActiveSigningWebKey(ctx context.Context) (*jose.JSONWebKey, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetActiveSigningWebKey", ctx)
ret0, _ := ret[0].(*jose.JSONWebKey)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetActiveSigningWebKey indicates an expected call of GetActiveSigningWebKey.
func (mr *MockQueriesMockRecorder) GetActiveSigningWebKey(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveSigningWebKey", reflect.TypeOf((*MockQueries)(nil).GetActiveSigningWebKey), ctx)
}
// GetDefaultLanguage mocks base method.
func (m *MockQueries) GetDefaultLanguage(ctx context.Context) language.Tag {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetDefaultLanguage", ctx)
ret0, _ := ret[0].(language.Tag)
return ret0
}
// GetDefaultLanguage indicates an expected call of GetDefaultLanguage.
func (mr *MockQueriesMockRecorder) GetDefaultLanguage(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDefaultLanguage", reflect.TypeOf((*MockQueries)(nil).GetDefaultLanguage), ctx)
}
// GetInstanceRestrictions mocks base method.
func (m *MockQueries) GetInstanceRestrictions(ctx context.Context) (query.Restrictions, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetInstanceRestrictions", ctx)
ret0, _ := ret[0].(query.Restrictions)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetInstanceRestrictions indicates an expected call of GetInstanceRestrictions.
func (mr *MockQueriesMockRecorder) GetInstanceRestrictions(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInstanceRestrictions", reflect.TypeOf((*MockQueries)(nil).GetInstanceRestrictions), ctx)
}
// GetNotifyUserByID mocks base method.
func (m *MockQueries) GetNotifyUserByID(ctx context.Context, shouldTriggered bool, userID string) (*query.NotifyUser, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetNotifyUserByID", ctx, shouldTriggered, userID)
ret0, _ := ret[0].(*query.NotifyUser)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetNotifyUserByID indicates an expected call of GetNotifyUserByID.
func (mr *MockQueriesMockRecorder) GetNotifyUserByID(ctx, shouldTriggered, userID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotifyUserByID", reflect.TypeOf((*MockQueries)(nil).GetNotifyUserByID), ctx, shouldTriggered, userID)
}
// InstanceByID mocks base method.
func (m *MockQueries) InstanceByID(ctx context.Context, id string) (authz.Instance, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "InstanceByID", ctx, id)
ret0, _ := ret[0].(authz.Instance)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// InstanceByID indicates an expected call of InstanceByID.
func (mr *MockQueriesMockRecorder) InstanceByID(ctx, id any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstanceByID", reflect.TypeOf((*MockQueries)(nil).InstanceByID), ctx, id)
}
// MailTemplateByOrg mocks base method.
func (m *MockQueries) MailTemplateByOrg(ctx context.Context, orgID string, withOwnerRemoved bool) (*query.MailTemplate, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "MailTemplateByOrg", ctx, orgID, withOwnerRemoved)
ret0, _ := ret[0].(*query.MailTemplate)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// MailTemplateByOrg indicates an expected call of MailTemplateByOrg.
func (mr *MockQueriesMockRecorder) MailTemplateByOrg(ctx, orgID, withOwnerRemoved any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MailTemplateByOrg", reflect.TypeOf((*MockQueries)(nil).MailTemplateByOrg), ctx, orgID, withOwnerRemoved)
}
// NotificationPolicyByOrg mocks base method.
func (m *MockQueries) NotificationPolicyByOrg(ctx context.Context, shouldTriggerBulk bool, orgID string, withOwnerRemoved bool) (*query.NotificationPolicy, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "NotificationPolicyByOrg", ctx, shouldTriggerBulk, orgID, withOwnerRemoved)
ret0, _ := ret[0].(*query.NotificationPolicy)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// NotificationPolicyByOrg indicates an expected call of NotificationPolicyByOrg.
func (mr *MockQueriesMockRecorder) NotificationPolicyByOrg(ctx, shouldTriggerBulk, orgID, withOwnerRemoved any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationPolicyByOrg", reflect.TypeOf((*MockQueries)(nil).NotificationPolicyByOrg), ctx, shouldTriggerBulk, orgID, withOwnerRemoved)
}
// NotificationProviderByIDAndType mocks base method.
func (m *MockQueries) NotificationProviderByIDAndType(ctx context.Context, aggID string, providerType domain.NotificationProviderType) (*query.DebugNotificationProvider, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "NotificationProviderByIDAndType", ctx, aggID, providerType)
ret0, _ := ret[0].(*query.DebugNotificationProvider)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// NotificationProviderByIDAndType indicates an expected call of NotificationProviderByIDAndType.
func (mr *MockQueriesMockRecorder) NotificationProviderByIDAndType(ctx, aggID, providerType any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationProviderByIDAndType", reflect.TypeOf((*MockQueries)(nil).NotificationProviderByIDAndType), ctx, aggID, providerType)
}
// SMSProviderConfigActive mocks base method.
func (m *MockQueries) SMSProviderConfigActive(ctx context.Context, resourceOwner string) (*query.SMSConfig, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SMSProviderConfigActive", ctx, resourceOwner)
ret0, _ := ret[0].(*query.SMSConfig)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// SMSProviderConfigActive indicates an expected call of SMSProviderConfigActive.
func (mr *MockQueriesMockRecorder) SMSProviderConfigActive(ctx, resourceOwner any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SMSProviderConfigActive", reflect.TypeOf((*MockQueries)(nil).SMSProviderConfigActive), ctx, resourceOwner)
}
// SMTPConfigActive mocks base method.
func (m *MockQueries) SMTPConfigActive(ctx context.Context, resourceOwner string) (*query.SMTPConfig, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SMTPConfigActive", ctx, resourceOwner)
ret0, _ := ret[0].(*query.SMTPConfig)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// SMTPConfigActive indicates an expected call of SMTPConfigActive.
func (mr *MockQueriesMockRecorder) SMTPConfigActive(ctx, resourceOwner any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SMTPConfigActive", reflect.TypeOf((*MockQueries)(nil).SMTPConfigActive), ctx, resourceOwner)
}
// SearchInstanceDomains mocks base method.
func (m *MockQueries) SearchInstanceDomains(ctx context.Context, queries *query.InstanceDomainSearchQueries) (*query.InstanceDomains, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SearchInstanceDomains", ctx, queries)
ret0, _ := ret[0].(*query.InstanceDomains)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// SearchInstanceDomains indicates an expected call of SearchInstanceDomains.
func (mr *MockQueriesMockRecorder) SearchInstanceDomains(ctx, queries any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchInstanceDomains", reflect.TypeOf((*MockQueries)(nil).SearchInstanceDomains), ctx, queries)
}
// SearchMilestones mocks base method.
func (m *MockQueries) SearchMilestones(ctx context.Context, instanceIDs []string, queries *query.MilestonesSearchQueries) (*query.Milestones, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SearchMilestones", ctx, instanceIDs, queries)
ret0, _ := ret[0].(*query.Milestones)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// SearchMilestones indicates an expected call of SearchMilestones.
func (mr *MockQueriesMockRecorder) SearchMilestones(ctx, instanceIDs, queries any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchMilestones", reflect.TypeOf((*MockQueries)(nil).SearchMilestones), ctx, instanceIDs, queries)
}
// SessionByID mocks base method.
func (m *MockQueries) SessionByID(ctx context.Context, shouldTriggerBulk bool, id, sessionToken string, check domain.PermissionCheck) (*query.Session, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SessionByID", ctx, shouldTriggerBulk, id, sessionToken, check)
ret0, _ := ret[0].(*query.Session)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// SessionByID indicates an expected call of SessionByID.
func (mr *MockQueriesMockRecorder) SessionByID(ctx, shouldTriggerBulk, id, sessionToken, check any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SessionByID", reflect.TypeOf((*MockQueries)(nil).SessionByID), ctx, shouldTriggerBulk, id, sessionToken, check)
}

View File

@@ -0,0 +1,62 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/zitadel/zitadel/internal/notification/handlers (interfaces: Queue)
//
// Generated by this command:
//
// mockgen -package mock -destination ./mock/queue.mock.go github.com/zitadel/zitadel/internal/notification/handlers Queue
//
// Package mock is a generated GoMock package.
package mock
import (
context "context"
reflect "reflect"
river "github.com/riverqueue/river"
queue "github.com/zitadel/zitadel/internal/queue"
gomock "go.uber.org/mock/gomock"
)
// MockQueue is a mock of Queue interface.
type MockQueue struct {
ctrl *gomock.Controller
recorder *MockQueueMockRecorder
isgomock struct{}
}
// MockQueueMockRecorder is the mock recorder for MockQueue.
type MockQueueMockRecorder struct {
mock *MockQueue
}
// NewMockQueue creates a new mock instance.
func NewMockQueue(ctrl *gomock.Controller) *MockQueue {
mock := &MockQueue{ctrl: ctrl}
mock.recorder = &MockQueueMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockQueue) EXPECT() *MockQueueMockRecorder {
return m.recorder
}
// Insert mocks base method.
func (m *MockQueue) Insert(ctx context.Context, args river.JobArgs, opts ...queue.InsertOpt) error {
m.ctrl.T.Helper()
varargs := []any{ctx, args}
for _, a := range opts {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "Insert", varargs...)
ret0, _ := ret[0].(error)
return ret0
}
// Insert indicates an expected call of Insert.
func (mr *MockQueueMockRecorder) Insert(ctx, args any, opts ...any) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]any{ctx, args}, opts...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Insert", reflect.TypeOf((*MockQueue)(nil).Insert), varargs...)
}

View File

@@ -0,0 +1,186 @@
package handlers
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/riverqueue/river"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/notification/channels"
"github.com/zitadel/zitadel/internal/notification/senders"
"github.com/zitadel/zitadel/internal/notification/types"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/repository/notification"
)
const (
Code = "Code"
OTP = "OTP"
)
type NotificationWorker struct {
river.WorkerDefaults[*notification.Request]
commands Commands
queries *NotificationQueries
channels types.ChannelChains
config WorkerConfig
now nowFunc
}
// Timeout implements the Timeout-function of [river.Worker].
// Maximum time a job can run before the context gets cancelled.
func (w *NotificationWorker) Timeout(*river.Job[*notification.Request]) time.Duration {
return w.config.TransactionDuration
}
// Work implements [river.Worker].
func (w *NotificationWorker) Work(ctx context.Context, job *river.Job[*notification.Request]) error {
ctx = ContextWithNotifier(ctx, job.Args.Aggregate)
// if the notification is too old, we can directly cancel
if job.CreatedAt.Add(w.config.MaxTtl).Before(w.now()) {
return river.JobCancel(errors.New("notification is too old"))
}
// We do not trigger the projection to reduce load on the database. By the time the notification is processed,
// the user should be projected anyway. If not, it will just wait for the next run.
// We are aware that the user can change during the time the notification is in the queue.
notifyUser, err := w.queries.GetNotifyUserByID(ctx, false, job.Args.UserID)
if err != nil {
return err
}
// The domain claimed event requires the domain as argument, but lacks the user when creating the request event.
// Since we set it into the request arguments, it will be passed into a potential retry event.
if job.Args.RequiresPreviousDomain && job.Args.Args != nil && job.Args.Args.Domain == "" {
index := strings.LastIndex(notifyUser.LastEmail, "@")
job.Args.Args.Domain = notifyUser.LastEmail[index+1:]
}
err = w.sendNotificationQueue(ctx, job.Args, strconv.Itoa(int(job.ID)), notifyUser)
if err == nil {
return nil
}
// if the error explicitly specifies, we cancel the notification
if errors.Is(err, &channels.CancelError{}) {
return river.JobCancel(err)
}
return err
}
type WorkerConfig struct {
LegacyEnabled bool
Workers uint8
TransactionDuration time.Duration
MaxTtl time.Duration
MaxAttempts uint8
}
// nowFunc makes [time.Now] mockable
type nowFunc func() time.Time
type Sent func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error
var sentHandlers map[eventstore.EventType]Sent
func RegisterSentHandler(eventType eventstore.EventType, sent Sent) {
if sentHandlers == nil {
sentHandlers = make(map[eventstore.EventType]Sent)
}
sentHandlers[eventType] = sent
}
func NewNotificationWorker(
config WorkerConfig,
commands Commands,
queries *NotificationQueries,
channels types.ChannelChains,
) *NotificationWorker {
return &NotificationWorker{
config: config,
commands: commands,
queries: queries,
channels: channels,
now: time.Now,
}
}
var _ river.Worker[*notification.Request] = (*NotificationWorker)(nil)
func (w *NotificationWorker) Register(workers *river.Workers, queues map[string]river.QueueConfig) {
river.AddWorker(workers, w)
queues[notification.QueueName] = river.QueueConfig{
MaxWorkers: int(w.config.Workers),
}
}
func (w *NotificationWorker) sendNotificationQueue(ctx context.Context, request *notification.Request, jobID string, notifyUser *query.NotifyUser) error {
// check early that a "sent" handler exists, otherwise we can cancel early
sentHandler, ok := sentHandlers[request.EventType]
if !ok {
logging.Errorf(`no "sent" handler registered for %s`, request.EventType)
return channels.NewCancelError(fmt.Errorf("no sent handler registered for %s", request.EventType))
}
ctx, err := enrichCtx(ctx, request.TriggeredAtOrigin)
if err != nil {
return channels.NewCancelError(err)
}
var code string
if request.Code != nil {
code, err = crypto.DecryptString(request.Code, w.queries.UserDataCrypto)
if err != nil {
return err
}
}
colors, err := w.queries.ActiveLabelPolicyByOrg(ctx, request.UserResourceOwner, false)
if err != nil {
return err
}
translator, err := w.queries.GetTranslatorWithOrgTexts(ctx, request.UserResourceOwner, request.MessageType)
if err != nil {
return err
}
generatorInfo := new(senders.CodeGeneratorInfo)
var notify types.Notify
switch request.NotificationType {
case domain.NotificationTypeEmail:
template, err := w.queries.MailTemplateByOrg(ctx, notifyUser.ResourceOwner, false)
if err != nil {
return err
}
notify = types.SendEmail(ctx, w.channels, string(template.Template), translator, notifyUser, colors, request.EventType)
case domain.NotificationTypeSms:
notify = types.SendSMS(ctx, w.channels, translator, notifyUser, colors, request.EventType, request.Aggregate.InstanceID, jobID, generatorInfo)
}
args := request.Args.ToMap()
args[Code] = code
// existing notifications use `OTP` as argument for the code
if request.IsOTP {
args[OTP] = code
}
if err = notify(request.URLTemplate, args, request.MessageType, request.UnverifiedNotificationChannel); err != nil {
return err
}
err = sentHandler(authz.WithInstanceID(ctx, request.Aggregate.InstanceID), w.commands, request.Aggregate.ID, request.Aggregate.ResourceOwner, generatorInfo, args)
logging.WithFields("instanceID", request.Aggregate.InstanceID, "notification", request.Aggregate.ID).
OnError(err).Error("could not set notification event on aggregate")
return nil
}

View File

@@ -0,0 +1,477 @@
package handlers
import (
"context"
"errors"
"fmt"
"testing"
"time"
"github.com/muhlemmer/gu"
"github.com/riverqueue/river"
"github.com/riverqueue/river/rivertype"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/ui/login"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
es_repo_mock "github.com/zitadel/zitadel/internal/eventstore/repository/mock"
"github.com/zitadel/zitadel/internal/notification/channels/email"
channel_mock "github.com/zitadel/zitadel/internal/notification/channels/mock"
"github.com/zitadel/zitadel/internal/notification/channels/sms"
"github.com/zitadel/zitadel/internal/notification/channels/smtp"
"github.com/zitadel/zitadel/internal/notification/channels/twilio"
"github.com/zitadel/zitadel/internal/notification/handlers/mock"
"github.com/zitadel/zitadel/internal/notification/messages"
"github.com/zitadel/zitadel/internal/notification/senders"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/repository/notification"
"github.com/zitadel/zitadel/internal/repository/session"
"github.com/zitadel/zitadel/internal/repository/user"
)
const (
notificationID = "notificationID"
)
func Test_userNotifier_reduceNotificationRequested(t *testing.T) {
testNow := time.Now
testBackOff := func(current time.Duration) time.Duration {
return time.Second
}
sendError := errors.New("send error")
tests := []struct {
name string
test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fieldsWorker, argsWorker, wantWorker)
}{
{
name: "too old",
test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) {
codeAlg, code := cryptoValue(t, ctrl, "testcode")
return fieldsWorker{
queries: queries,
commands: commands,
es: eventstore.NewEventstore(&eventstore.Config{
Querier: es_repo_mock.NewRepo(t).MockQuerier,
}),
userDataCrypto: codeAlg,
now: testNow,
},
argsWorker{
job: &river.Job[*notification.Request]{
JobRow: &rivertype.JobRow{
CreatedAt: time.Now().Add(-1 * time.Hour),
},
Args: &notification.Request{
Aggregate: &eventstore.Aggregate{
InstanceID: instanceID,
ID: notificationID,
ResourceOwner: instanceID,
},
UserID: userID,
UserResourceOwner: orgID,
TriggeredAtOrigin: eventOrigin,
EventType: user.HumanInviteCodeAddedType,
MessageType: domain.InviteUserMessageType,
NotificationType: domain.NotificationTypeEmail,
URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID),
CodeExpiry: 1 * time.Hour,
Code: code,
UnverifiedNotificationChannel: true,
IsOTP: false,
RequiresPreviousDomain: false,
Args: &domain.NotificationArguments{
ApplicationName: "APP",
},
},
},
},
wantWorker{
err: func(tt assert.TestingT, err error, i ...interface{}) bool {
return errors.Is(err, new(river.JobCancelError))
},
}
},
},
{
name: "send ok (email)",
test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) {
givenTemplate := "{{.LogoURL}}"
expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL)
w.message = &messages.Email{
Recipients: []string{lastEmail},
Subject: "Invitation to APP",
Content: expectContent,
TriggeringEventType: user.HumanInviteCodeAddedType,
}
codeAlg, code := cryptoValue(t, ctrl, "testcode")
expectTemplateWithNotifyUserQueries(queries, givenTemplate)
commands.EXPECT().InviteCodeSent(gomock.Any(), orgID, userID).Return(nil)
return fieldsWorker{
queries: queries,
commands: commands,
es: eventstore.NewEventstore(&eventstore.Config{
Querier: es_repo_mock.NewRepo(t).MockQuerier,
}),
userDataCrypto: codeAlg,
now: testNow,
},
argsWorker{
job: &river.Job[*notification.Request]{
JobRow: &rivertype.JobRow{
CreatedAt: time.Now(),
},
Args: &notification.Request{
Aggregate: &eventstore.Aggregate{
InstanceID: instanceID,
ID: userID,
ResourceOwner: orgID,
},
UserID: userID,
UserResourceOwner: orgID,
TriggeredAtOrigin: eventOrigin,
EventType: user.HumanInviteCodeAddedType,
MessageType: domain.InviteUserMessageType,
NotificationType: domain.NotificationTypeEmail,
URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID),
CodeExpiry: 1 * time.Hour,
Code: code,
UnverifiedNotificationChannel: true,
IsOTP: false,
RequiresPreviousDomain: false,
Args: &domain.NotificationArguments{
ApplicationName: "APP",
},
},
},
},
w
},
},
{
name: "send ok (sms with external provider)",
test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) {
expiry := 0 * time.Hour
testCode := ""
expectContent := fmt.Sprintf(`%[1]s is your one-time password for %[2]s. Use it within the next %[3]s.
@%[2]s #%[1]s`, testCode, eventOriginDomain, expiry)
w.messageSMS = &messages.SMS{
SenderPhoneNumber: "senderNumber",
RecipientPhoneNumber: verifiedPhone,
Content: expectContent,
TriggeringEventType: session.OTPSMSChallengedType,
InstanceID: instanceID,
JobID: "1",
UserID: userID,
}
codeAlg, code := cryptoValue(t, ctrl, testCode)
expectTemplateWithNotifyUserQueriesSMS(queries)
commands.EXPECT().OTPSMSSent(gomock.Any(), sessionID, instanceID, &senders.CodeGeneratorInfo{
ID: smsProviderID,
VerificationID: verificationID,
}).Return(nil)
return fieldsWorker{
queries: queries,
commands: commands,
es: eventstore.NewEventstore(&eventstore.Config{
Querier: es_repo_mock.NewRepo(t).MockQuerier,
}),
userDataCrypto: codeAlg,
now: testNow,
},
argsWorker{
job: &river.Job[*notification.Request]{
JobRow: &rivertype.JobRow{
CreatedAt: time.Now(),
ID: 1,
},
Args: &notification.Request{
Aggregate: &eventstore.Aggregate{
InstanceID: instanceID,
ID: sessionID,
ResourceOwner: instanceID,
},
UserID: userID,
UserResourceOwner: orgID,
TriggeredAtOrigin: eventOrigin,
EventType: session.OTPSMSChallengedType,
MessageType: domain.VerifySMSOTPMessageType,
NotificationType: domain.NotificationTypeSms,
URLTemplate: "",
CodeExpiry: expiry,
Code: code,
UnverifiedNotificationChannel: false,
IsOTP: true,
RequiresPreviousDomain: false,
Args: &domain.NotificationArguments{
Origin: eventOrigin,
Domain: eventOriginDomain,
Expiry: expiry,
},
},
},
}, w
},
},
{
name: "previous domain",
test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) {
givenTemplate := "{{.LogoURL}}"
expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL)
w.message = &messages.Email{
Recipients: []string{verifiedEmail},
Subject: "Domain has been claimed",
Content: expectContent,
TriggeringEventType: user.UserDomainClaimedType,
}
expectTemplateWithNotifyUserQueries(queries, givenTemplate)
commands.EXPECT().UserDomainClaimedSent(gomock.Any(), orgID, userID).Return(nil)
return fieldsWorker{
queries: queries,
commands: commands,
es: eventstore.NewEventstore(&eventstore.Config{
Querier: es_repo_mock.NewRepo(t).MockQuerier,
}),
userDataCrypto: nil,
now: testNow,
},
argsWorker{
job: &river.Job[*notification.Request]{
JobRow: &rivertype.JobRow{
CreatedAt: time.Now(),
},
Args: &notification.Request{
Aggregate: &eventstore.Aggregate{
InstanceID: instanceID,
ID: userID,
ResourceOwner: orgID,
},
UserID: userID,
UserResourceOwner: orgID,
TriggeredAtOrigin: eventOrigin,
EventType: user.UserDomainClaimedType,
MessageType: domain.DomainClaimedMessageType,
NotificationType: domain.NotificationTypeEmail,
URLTemplate: login.LoginLink(eventOrigin, orgID),
CodeExpiry: 0,
Code: nil,
UnverifiedNotificationChannel: false,
IsOTP: false,
RequiresPreviousDomain: true,
Args: &domain.NotificationArguments{
TempUsername: "tempUsername",
},
},
},
}, w
},
},
{
name: "send failed, retry",
test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) {
givenTemplate := "{{.LogoURL}}"
expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL)
w.message = &messages.Email{
Recipients: []string{lastEmail},
Subject: "Invitation to APP",
Content: expectContent,
TriggeringEventType: user.HumanInviteCodeAddedType,
}
w.sendError = sendError
w.err = func(tt assert.TestingT, err error, i ...interface{}) bool {
return errors.Is(err, sendError)
}
codeAlg, code := cryptoValue(t, ctrl, "testcode")
expectTemplateWithNotifyUserQueries(queries, givenTemplate)
return fieldsWorker{
queries: queries,
commands: commands,
es: eventstore.NewEventstore(&eventstore.Config{
Querier: es_repo_mock.NewRepo(t).MockQuerier,
}),
userDataCrypto: codeAlg,
now: testNow,
backOff: testBackOff,
},
argsWorker{
job: &river.Job[*notification.Request]{
JobRow: &rivertype.JobRow{
ID: 1,
CreatedAt: time.Now(),
},
Args: &notification.Request{
Aggregate: &eventstore.Aggregate{
InstanceID: instanceID,
ID: notificationID,
ResourceOwner: instanceID,
},
UserID: userID,
UserResourceOwner: orgID,
TriggeredAtOrigin: eventOrigin,
EventType: user.HumanInviteCodeAddedType,
MessageType: domain.InviteUserMessageType,
NotificationType: domain.NotificationTypeEmail,
URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID),
CodeExpiry: 1 * time.Hour,
Code: code,
UnverifiedNotificationChannel: true,
IsOTP: false,
RequiresPreviousDomain: false,
Args: &domain.NotificationArguments{
ApplicationName: "APP",
},
},
},
},
w
},
},
{
name: "send failed (max attempts), cancel",
test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) {
givenTemplate := "{{.LogoURL}}"
expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL)
w.message = &messages.Email{
Recipients: []string{lastEmail},
Subject: "Invitation to APP",
Content: expectContent,
TriggeringEventType: user.HumanInviteCodeAddedType,
}
w.sendError = sendError
w.err = func(tt assert.TestingT, err error, i ...interface{}) bool {
return err != nil
}
codeAlg, code := cryptoValue(t, ctrl, "testcode")
expectTemplateWithNotifyUserQueries(queries, givenTemplate)
return fieldsWorker{
queries: queries,
commands: commands,
es: eventstore.NewEventstore(&eventstore.Config{
Querier: es_repo_mock.NewRepo(t).MockQuerier,
}),
userDataCrypto: codeAlg,
now: testNow,
backOff: testBackOff,
},
argsWorker{
job: &river.Job[*notification.Request]{
JobRow: &rivertype.JobRow{
CreatedAt: time.Now(),
},
Args: &notification.Request{
Aggregate: &eventstore.Aggregate{
InstanceID: instanceID,
ID: userID,
ResourceOwner: orgID,
},
UserID: userID,
UserResourceOwner: orgID,
TriggeredAtOrigin: eventOrigin,
EventType: user.HumanInviteCodeAddedType,
MessageType: domain.InviteUserMessageType,
NotificationType: domain.NotificationTypeEmail,
URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID),
CodeExpiry: 1 * time.Hour,
Code: code,
UnverifiedNotificationChannel: true,
IsOTP: false,
RequiresPreviousDomain: false,
Args: &domain.NotificationArguments{
ApplicationName: "APP",
},
},
},
},
w
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
queries := mock.NewMockQueries(ctrl)
commands := mock.NewMockCommands(ctrl)
f, a, w := tt.test(ctrl, queries, commands)
err := newNotificationWorker(t, ctrl, queries, f, w).Work(
authz.WithInstanceID(context.Background(), instanceID),
a.job,
)
if w.err != nil {
w.err(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func newNotificationWorker(t *testing.T, ctrl *gomock.Controller, queries *mock.MockQueries, f fieldsWorker, w wantWorker) *NotificationWorker {
queries.EXPECT().NotificationProviderByIDAndType(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().Return(&query.DebugNotificationProvider{}, nil)
smtpAlg, _ := cryptoValue(t, ctrl, "smtppw")
channel := channel_mock.NewMockNotificationChannel(ctrl)
if w.message != nil {
channel.EXPECT().HandleMessage(w.message).Return(w.sendError)
}
if w.messageSMS != nil {
channel.EXPECT().HandleMessage(w.messageSMS).DoAndReturn(func(message *messages.SMS) error {
message.VerificationID = gu.Ptr(verificationID)
return w.sendError
})
}
return &NotificationWorker{
commands: f.commands,
queries: NewNotificationQueries(
f.queries,
f.es,
externalDomain,
externalPort,
externalSecure,
"",
f.userDataCrypto,
smtpAlg,
f.SMSTokenCrypto,
),
channels: &notificationChannels{
Chain: *senders.ChainChannels(channel),
EmailConfig: &email.Config{
ProviderConfig: &email.Provider{
ID: "emailProviderID",
Description: "description",
},
SMTPConfig: &smtp.Config{
SMTP: smtp.SMTP{
Host: "host",
User: "user",
Password: "password",
},
Tls: true,
From: "from",
FromName: "fromName",
ReplyToAddress: "replyToAddress",
},
WebhookConfig: nil,
},
SMSConfig: &sms.Config{
ProviderConfig: &sms.Provider{
ID: "smsProviderID",
Description: "description",
},
TwilioConfig: &twilio.Config{
SID: "sid",
Token: "token",
SenderNumber: "senderNumber",
VerifyServiceSID: "verifyServiceSID",
},
},
},
config: WorkerConfig{
Workers: 1,
TransactionDuration: 5 * time.Second,
MaxTtl: 5 * time.Minute,
},
now: f.now,
}
}

View File

@@ -0,0 +1,61 @@
package handlers
import (
"context"
"net/url"
"github.com/zitadel/logging"
http_utils "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/zerrors"
)
type OriginEvent interface {
eventstore.Event
TriggerOrigin() string
}
func (n *NotificationQueries) Origin(ctx context.Context, e eventstore.Event) (context.Context, error) {
var origin string
originEvent, ok := e.(OriginEvent)
if !ok {
logging.Errorf("event of type %T doesn't implement OriginEvent", e)
} else {
origin = originEvent.TriggerOrigin()
}
if origin != "" {
return enrichCtx(ctx, origin)
}
primary, err := query.NewInstanceDomainPrimarySearchQuery(true)
if err != nil {
return ctx, err
}
domains, err := n.SearchInstanceDomains(ctx, &query.InstanceDomainSearchQueries{
Queries: []query.SearchQuery{primary},
})
if err != nil {
return ctx, err
}
if len(domains.Domains) < 1 {
return ctx, zerrors.ThrowInternal(nil, "NOTIF-Ef3r1", "Errors.Notification.NoDomain")
}
return enrichCtx(
ctx,
http_utils.BuildHTTP(domains.Domains[0].Domain, n.externalPort, n.externalSecure),
)
}
func enrichCtx(ctx context.Context, origin string) (context.Context, error) {
u, err := url.Parse(origin)
if err != nil {
return nil, err
}
ctx = http_utils.WithDomainContext(ctx, &http_utils.DomainCtx{
InstanceHost: u.Host,
PublicHost: u.Host,
Protocol: u.Scheme,
})
return ctx, nil
}

View File

@@ -0,0 +1,70 @@
package handlers
import (
"context"
"github.com/go-jose/go-jose/v4"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/query"
)
type Queries interface {
ActiveLabelPolicyByOrg(ctx context.Context, orgID string, withOwnerRemoved bool) (*query.LabelPolicy, error)
MailTemplateByOrg(ctx context.Context, orgID string, withOwnerRemoved bool) (*query.MailTemplate, error)
GetNotifyUserByID(ctx context.Context, shouldTriggered bool, userID string) (*query.NotifyUser, error)
CustomTextListByTemplate(ctx context.Context, aggregateID, template string, withOwnerRemoved bool) (*query.CustomTexts, error)
SearchInstanceDomains(ctx context.Context, queries *query.InstanceDomainSearchQueries) (*query.InstanceDomains, error)
SessionByID(ctx context.Context, shouldTriggerBulk bool, id, sessionToken string, check domain.PermissionCheck) (*query.Session, error)
NotificationPolicyByOrg(ctx context.Context, shouldTriggerBulk bool, orgID string, withOwnerRemoved bool) (*query.NotificationPolicy, error)
SearchMilestones(ctx context.Context, instanceIDs []string, queries *query.MilestonesSearchQueries) (*query.Milestones, error)
NotificationProviderByIDAndType(ctx context.Context, aggID string, providerType domain.NotificationProviderType) (*query.DebugNotificationProvider, error)
SMSProviderConfigActive(ctx context.Context, resourceOwner string) (config *query.SMSConfig, err error)
SMTPConfigActive(ctx context.Context, resourceOwner string) (*query.SMTPConfig, error)
GetDefaultLanguage(ctx context.Context) language.Tag
GetInstanceRestrictions(ctx context.Context) (restrictions query.Restrictions, err error)
InstanceByID(ctx context.Context, id string) (instance authz.Instance, err error)
GetActiveSigningWebKey(ctx context.Context) (*jose.JSONWebKey, error)
ActiveInstances() []string
}
type NotificationQueries struct {
Queries
es *eventstore.Eventstore
externalDomain string
externalPort uint16
externalSecure bool
fileSystemPath string
UserDataCrypto crypto.EncryptionAlgorithm
SMTPPasswordCrypto crypto.EncryptionAlgorithm
SMSTokenCrypto crypto.EncryptionAlgorithm
}
func NewNotificationQueries(
baseQueries Queries,
es *eventstore.Eventstore,
externalDomain string,
externalPort uint16,
externalSecure bool,
fileSystemPath string,
userDataCrypto crypto.EncryptionAlgorithm,
smtpPasswordCrypto crypto.EncryptionAlgorithm,
smsTokenCrypto crypto.EncryptionAlgorithm,
) *NotificationQueries {
return &NotificationQueries{
Queries: baseQueries,
es: es,
externalDomain: externalDomain,
externalPort: externalPort,
externalSecure: externalSecure,
fileSystemPath: fileSystemPath,
UserDataCrypto: userDataCrypto,
SMTPPasswordCrypto: smtpPasswordCrypto,
SMSTokenCrypto: smsTokenCrypto,
}
}

View File

@@ -0,0 +1,13 @@
package handlers
import (
"context"
"github.com/riverqueue/river"
"github.com/zitadel/zitadel/internal/queue"
)
type Queue interface {
Insert(ctx context.Context, args river.JobArgs, opts ...queue.InsertOpt) error
}

View File

@@ -0,0 +1,80 @@
package handlers
import (
"context"
"net/http"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/handler/v2"
"github.com/zitadel/zitadel/internal/notification/channels/webhook"
_ "github.com/zitadel/zitadel/internal/notification/statik"
"github.com/zitadel/zitadel/internal/notification/types"
"github.com/zitadel/zitadel/internal/repository/quota"
"github.com/zitadel/zitadel/internal/zerrors"
)
const (
QuotaNotificationsProjectionTable = "projections.notifications_quota"
)
type quotaNotifier struct {
commands *command.Commands
queries *NotificationQueries
channels types.ChannelChains
}
func NewQuotaNotifier(
ctx context.Context,
config handler.Config,
commands *command.Commands,
queries *NotificationQueries,
channels types.ChannelChains,
) *handler.Handler {
return handler.NewHandler(ctx, &config, &quotaNotifier{
commands: commands,
queries: queries,
channels: channels,
})
}
func (*quotaNotifier) Name() string {
return QuotaNotificationsProjectionTable
}
func (u *quotaNotifier) Reducers() []handler.AggregateReducer {
return []handler.AggregateReducer{
{
Aggregate: quota.AggregateType,
EventReducers: []handler.EventReducer{
{
Event: quota.NotificationDueEventType,
Reduce: u.reduceNotificationDue,
},
},
},
}
}
func (u *quotaNotifier) reduceNotificationDue(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*quota.NotificationDueEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-DLxdE", "reduce.wrong.event.type %s", quota.NotificationDueEventType)
}
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
ctx = HandlerContext(ctx, event.Aggregate())
alreadyHandled, err := u.queries.IsAlreadyHandled(ctx, event, map[string]interface{}{"dueEventID": e.ID}, quota.NotifiedEventType)
if err != nil {
return err
}
if alreadyHandled {
return nil
}
err = types.SendJSON(ctx, webhook.Config{CallURL: e.CallURL, Method: http.MethodPost}, u.channels, e, e.Type()).WithoutTemplate()
if err != nil {
return err
}
return u.commands.UsageNotificationSent(ctx, e)
}), nil
}

View File

@@ -0,0 +1,111 @@
package handlers
import (
"context"
"net/http"
"time"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/handler/v2"
"github.com/zitadel/zitadel/internal/notification/channels/webhook"
_ "github.com/zitadel/zitadel/internal/notification/statik"
"github.com/zitadel/zitadel/internal/notification/types"
"github.com/zitadel/zitadel/internal/repository/milestone"
"github.com/zitadel/zitadel/internal/zerrors"
)
const (
TelemetryProjectionTable = "projections.telemetry"
)
type TelemetryPusherConfig struct {
Enabled bool
Endpoints []string
Headers http.Header
}
type telemetryPusher struct {
cfg TelemetryPusherConfig
commands *command.Commands
queries *NotificationQueries
channels types.ChannelChains
}
func NewTelemetryPusher(
ctx context.Context,
telemetryCfg TelemetryPusherConfig,
handlerCfg handler.Config,
commands *command.Commands,
queries *NotificationQueries,
channels types.ChannelChains,
) *handler.Handler {
pusher := &telemetryPusher{
cfg: telemetryCfg,
commands: commands,
queries: queries,
channels: channels,
}
return handler.NewHandler(
ctx,
&handlerCfg,
pusher,
)
}
func (u *telemetryPusher) Name() string {
return TelemetryProjectionTable
}
func (t *telemetryPusher) Reducers() []handler.AggregateReducer {
return []handler.AggregateReducer{{
Aggregate: milestone.AggregateType,
EventReducers: []handler.EventReducer{{
Event: milestone.ReachedEventType,
Reduce: t.pushMilestones,
}},
}}
}
func (t *telemetryPusher) pushMilestones(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*milestone.ReachedEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-lDTs5", "reduce.wrong.event.type %s", event.Type())
}
return handler.NewStatement(event, func(ctx context.Context, _ handler.Executer, _ string) error {
// Do not push the milestone again if this was a migration event.
if e.ReachedDate != nil {
return nil
}
return t.pushMilestone(ctx, e)
}), nil
}
func (t *telemetryPusher) pushMilestone(ctx context.Context, e *milestone.ReachedEvent) error {
for _, endpoint := range t.cfg.Endpoints {
if err := types.SendJSON(
ctx,
webhook.Config{
CallURL: endpoint,
Method: http.MethodPost,
Headers: t.cfg.Headers,
},
t.channels,
&struct {
InstanceID string `json:"instanceId"`
ExternalDomain string `json:"externalDomain"`
Type milestone.Type `json:"type"`
ReachedDate time.Time `json:"reached"`
}{
InstanceID: e.Agg.InstanceID,
ExternalDomain: t.queries.externalDomain,
Type: e.MilestoneType,
ReachedDate: e.GetReachedDate(),
},
e.EventType,
).WithoutTemplate(); err != nil {
return err
}
}
return t.commands.MilestonePushed(ctx, e.Agg.InstanceID, e.MilestoneType, t.cfg.Endpoints)
}

View File

@@ -0,0 +1,43 @@
package handlers
import (
"context"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/i18n"
)
func (n *NotificationQueries) GetTranslatorWithOrgTexts(ctx context.Context, orgID, textType string) (*i18n.Translator, error) {
restrictions, err := n.Queries.GetInstanceRestrictions(ctx)
if err != nil {
return nil, err
}
translator, err := i18n.NewNotificationTranslator(n.GetDefaultLanguage(ctx), restrictions.AllowedLanguages)
if err != nil {
return nil, err
}
allCustomTexts, err := n.CustomTextListByTemplate(ctx, authz.GetInstance(ctx).InstanceID(), textType, false)
if err != nil {
return translator, nil
}
customTexts, err := n.CustomTextListByTemplate(ctx, orgID, textType, false)
if err != nil {
return translator, nil
}
allCustomTexts.CustomTexts = append(allCustomTexts.CustomTexts, customTexts.CustomTexts...)
for _, text := range allCustomTexts.CustomTexts {
msg := i18n.Message{
ID: text.Template + "." + text.Key,
Text: text.Text,
}
err = translator.AddMessages(text.Language, msg)
logging.WithFields("instanceID", authz.GetInstance(ctx).InstanceID(), "orgID", orgID, "messageType", textType, "messageID", msg.ID).
OnError(err).
Warn("could not add translation message")
}
return translator, nil
}

View File

@@ -0,0 +1,828 @@
package handlers
import (
"context"
"time"
http_util "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/api/ui/console"
"github.com/zitadel/zitadel/internal/api/ui/login"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/handler/v2"
"github.com/zitadel/zitadel/internal/notification/senders"
"github.com/zitadel/zitadel/internal/notification/types"
"github.com/zitadel/zitadel/internal/queue"
"github.com/zitadel/zitadel/internal/repository/notification"
"github.com/zitadel/zitadel/internal/repository/session"
"github.com/zitadel/zitadel/internal/repository/user"
"github.com/zitadel/zitadel/internal/zerrors"
)
func init() {
RegisterSentHandler(user.HumanInitialCodeAddedType,
func(ctx context.Context, commands Commands, id, orgID string, _ *senders.CodeGeneratorInfo, args map[string]any) error {
return commands.HumanInitCodeSent(ctx, orgID, id)
},
)
RegisterSentHandler(user.HumanEmailCodeAddedType,
func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error {
return commands.HumanEmailVerificationCodeSent(ctx, orgID, id)
},
)
RegisterSentHandler(user.HumanPasswordCodeAddedType,
func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error {
return commands.PasswordCodeSent(ctx, orgID, id, generatorInfo)
},
)
RegisterSentHandler(user.HumanOTPSMSCodeAddedType,
func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error {
return commands.HumanOTPSMSCodeSent(ctx, id, orgID, generatorInfo)
},
)
RegisterSentHandler(session.OTPSMSChallengedType,
func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error {
return commands.OTPSMSSent(ctx, id, orgID, generatorInfo)
},
)
RegisterSentHandler(user.HumanOTPEmailCodeAddedType,
func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error {
return commands.HumanOTPEmailCodeSent(ctx, id, orgID)
},
)
RegisterSentHandler(session.OTPEmailChallengedType,
func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error {
return commands.OTPEmailSent(ctx, id, orgID)
},
)
RegisterSentHandler(user.UserDomainClaimedType,
func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error {
return commands.UserDomainClaimedSent(ctx, orgID, id)
},
)
RegisterSentHandler(user.HumanPasswordlessInitCodeRequestedType,
func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error {
return commands.HumanPasswordlessInitCodeSent(ctx, id, orgID, args["CodeID"].(string))
},
)
RegisterSentHandler(user.HumanPasswordChangedType,
func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error {
return commands.PasswordChangeSent(ctx, orgID, id)
},
)
RegisterSentHandler(user.HumanPhoneCodeAddedType,
func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error {
return commands.HumanPhoneVerificationCodeSent(ctx, orgID, id, generatorInfo)
},
)
RegisterSentHandler(user.HumanInviteCodeAddedType,
func(ctx context.Context, commands Commands, id, orgID string, _ *senders.CodeGeneratorInfo, args map[string]any) error {
return commands.InviteCodeSent(ctx, orgID, id)
},
)
}
const (
UserNotificationsProjectionTable = "projections.notifications"
)
type userNotifier struct {
queries *NotificationQueries
otpEmailTmpl string
queue Queue
maxAttempts uint8
}
func NewUserNotifier(
ctx context.Context,
config handler.Config,
commands Commands,
queries *NotificationQueries,
channels types.ChannelChains,
otpEmailTmpl string,
workerConfig WorkerConfig,
queue Queue,
) *handler.Handler {
if workerConfig.LegacyEnabled {
return NewUserNotifierLegacy(ctx, config, commands, queries, channels, otpEmailTmpl)
}
return handler.NewHandler(ctx, &config, &userNotifier{
queries: queries,
otpEmailTmpl: otpEmailTmpl,
queue: queue,
maxAttempts: workerConfig.MaxAttempts,
})
}
func (u *userNotifier) Name() string {
return UserNotificationsProjectionTable
}
func (u *userNotifier) Reducers() []handler.AggregateReducer {
return []handler.AggregateReducer{
{
Aggregate: user.AggregateType,
EventReducers: []handler.EventReducer{
{
Event: user.UserV1InitialCodeAddedType,
Reduce: u.reduceInitCodeAdded,
},
{
Event: user.HumanInitialCodeAddedType,
Reduce: u.reduceInitCodeAdded,
},
{
Event: user.UserV1EmailCodeAddedType,
Reduce: u.reduceEmailCodeAdded,
},
{
Event: user.HumanEmailCodeAddedType,
Reduce: u.reduceEmailCodeAdded,
},
{
Event: user.UserV1PasswordCodeAddedType,
Reduce: u.reducePasswordCodeAdded,
},
{
Event: user.HumanPasswordCodeAddedType,
Reduce: u.reducePasswordCodeAdded,
},
{
Event: user.UserDomainClaimedType,
Reduce: u.reduceDomainClaimed,
},
{
Event: user.HumanPasswordlessInitCodeRequestedType,
Reduce: u.reducePasswordlessCodeRequested,
},
{
Event: user.UserV1PhoneCodeAddedType,
Reduce: u.reducePhoneCodeAdded,
},
{
Event: user.HumanPhoneCodeAddedType,
Reduce: u.reducePhoneCodeAdded,
},
{
Event: user.HumanPasswordChangedType,
Reduce: u.reducePasswordChanged,
},
{
Event: user.HumanOTPSMSCodeAddedType,
Reduce: u.reduceOTPSMSCodeAdded,
},
{
Event: user.HumanOTPEmailCodeAddedType,
Reduce: u.reduceOTPEmailCodeAdded,
},
{
Event: user.HumanInviteCodeAddedType,
Reduce: u.reduceInviteCodeAdded,
},
},
},
{
Aggregate: session.AggregateType,
EventReducers: []handler.EventReducer{
{
Event: session.OTPSMSChallengedType,
Reduce: u.reduceSessionOTPSMSChallenged,
},
{
Event: session.OTPEmailChallengedType,
Reduce: u.reduceSessionOTPEmailChallenged,
},
},
},
}
}
func (u *userNotifier) reduceInitCodeAdded(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*user.HumanInitialCodeAddedEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-EFe2f", "reduce.wrong.event.type %s", user.HumanInitialCodeAddedType)
}
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
ctx = HandlerContext(ctx, event.Aggregate())
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
user.UserV1InitialCodeAddedType, user.UserV1InitialCodeSentType,
user.HumanInitialCodeAddedType, user.HumanInitialCodeSentType)
if err != nil {
return err
}
if alreadyHandled {
return nil
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
origin := http_util.DomainContext(ctx).Origin()
return u.queue.Insert(ctx,
&notification.Request{
Aggregate: e.Aggregate(),
UserID: e.Aggregate().ID,
UserResourceOwner: e.Aggregate().ResourceOwner,
TriggeredAtOrigin: origin,
EventType: e.EventType,
NotificationType: domain.NotificationTypeEmail,
MessageType: domain.InitCodeMessageType,
Code: e.Code,
CodeExpiry: e.Expiry,
IsOTP: false,
UnverifiedNotificationChannel: true,
URLTemplate: login.InitUserLinkTemplate(origin, e.Aggregate().ID, e.Aggregate().ResourceOwner, e.AuthRequestID),
Args: &domain.NotificationArguments{
AuthRequestID: e.AuthRequestID,
},
},
queue.WithQueueName(notification.QueueName),
queue.WithMaxAttempts(u.maxAttempts),
)
}), nil
}
func (u *userNotifier) reduceEmailCodeAdded(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*user.HumanEmailCodeAddedEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-SWf3g", "reduce.wrong.event.type %s", user.HumanEmailCodeAddedType)
}
if e.CodeReturned {
return handler.NewNoOpStatement(e), nil
}
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
ctx = HandlerContext(ctx, event.Aggregate())
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
user.UserV1EmailCodeAddedType, user.UserV1EmailCodeSentType,
user.HumanEmailCodeAddedType, user.HumanEmailCodeSentType)
if err != nil {
return err
}
if alreadyHandled {
return nil
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
origin := http_util.DomainContext(ctx).Origin()
return u.queue.Insert(ctx,
&notification.Request{
Aggregate: e.Aggregate(),
UserID: e.Aggregate().ID,
UserResourceOwner: e.Aggregate().ResourceOwner,
TriggeredAtOrigin: origin,
EventType: e.EventType,
NotificationType: domain.NotificationTypeEmail,
MessageType: domain.VerifyEmailMessageType,
Code: e.Code,
CodeExpiry: e.Expiry,
IsOTP: false,
UnverifiedNotificationChannel: true,
URLTemplate: u.emailCodeTemplate(origin, e),
Args: &domain.NotificationArguments{
AuthRequestID: e.AuthRequestID,
},
},
queue.WithQueueName(notification.QueueName),
queue.WithMaxAttempts(u.maxAttempts),
)
}), nil
}
func (u *userNotifier) emailCodeTemplate(origin string, e *user.HumanEmailCodeAddedEvent) string {
if e.URLTemplate != "" {
return e.URLTemplate
}
return login.MailVerificationLinkTemplate(origin, e.Aggregate().ID, e.Aggregate().ResourceOwner, e.AuthRequestID)
}
func (u *userNotifier) reducePasswordCodeAdded(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*user.HumanPasswordCodeAddedEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Eeg3s", "reduce.wrong.event.type %s", user.HumanPasswordCodeAddedType)
}
if e.CodeReturned {
return handler.NewNoOpStatement(e), nil
}
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
ctx = HandlerContext(ctx, event.Aggregate())
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
user.UserV1PasswordCodeAddedType, user.UserV1PasswordCodeSentType,
user.HumanPasswordCodeAddedType, user.HumanPasswordCodeSentType)
if err != nil {
return err
}
if alreadyHandled {
return nil
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
origin := http_util.DomainContext(ctx).Origin()
return u.queue.Insert(ctx,
&notification.Request{
Aggregate: e.Aggregate(),
UserID: e.Aggregate().ID,
UserResourceOwner: e.Aggregate().ResourceOwner,
TriggeredAtOrigin: origin,
EventType: e.EventType,
NotificationType: e.NotificationType,
MessageType: domain.PasswordResetMessageType,
Code: e.Code,
CodeExpiry: e.Expiry,
IsOTP: false,
UnverifiedNotificationChannel: true,
URLTemplate: u.passwordCodeTemplate(origin, e),
Args: &domain.NotificationArguments{
AuthRequestID: e.AuthRequestID,
},
},
queue.WithQueueName(notification.QueueName),
queue.WithMaxAttempts(u.maxAttempts),
)
}), nil
}
func (u *userNotifier) passwordCodeTemplate(origin string, e *user.HumanPasswordCodeAddedEvent) string {
if e.URLTemplate != "" {
return e.URLTemplate
}
return login.InitPasswordLinkTemplate(origin, e.Aggregate().ID, e.Aggregate().ResourceOwner, e.AuthRequestID)
}
func (u *userNotifier) reduceOTPSMSCodeAdded(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*user.HumanOTPSMSCodeAddedEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-ASF3g", "reduce.wrong.event.type %s", user.HumanOTPSMSCodeAddedType)
}
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
ctx = HandlerContext(ctx, event.Aggregate())
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
user.HumanOTPSMSCodeAddedType,
user.HumanOTPSMSCodeSentType)
if err != nil {
return err
}
if alreadyHandled {
return nil
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
return u.queue.Insert(ctx,
&notification.Request{
Aggregate: e.Aggregate(),
UserID: e.Aggregate().ID,
UserResourceOwner: e.Aggregate().ResourceOwner,
TriggeredAtOrigin: http_util.DomainContext(ctx).Origin(),
EventType: e.EventType,
NotificationType: domain.NotificationTypeSms,
MessageType: domain.VerifySMSOTPMessageType,
Code: e.Code,
CodeExpiry: e.Expiry,
IsOTP: true,
Args: otpArgs(ctx, e.Expiry),
},
queue.WithQueueName(notification.QueueName),
queue.WithMaxAttempts(u.maxAttempts),
)
}), nil
}
func (u *userNotifier) reduceSessionOTPSMSChallenged(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*session.OTPSMSChallengedEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Sk32L", "reduce.wrong.event.type %s", session.OTPSMSChallengedType)
}
if e.CodeReturned {
return handler.NewNoOpStatement(e), nil
}
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
ctx = HandlerContext(ctx, event.Aggregate())
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
session.OTPSMSChallengedType,
session.OTPSMSSentType)
if err != nil {
return err
}
if alreadyHandled {
return nil
}
s, err := u.queries.SessionByID(ctx, true, e.Aggregate().ID, "", nil)
if err != nil {
return err
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
args := otpArgs(ctx, e.Expiry)
args.SessionID = e.Aggregate().ID
return u.queue.Insert(ctx,
&notification.Request{
Aggregate: e.Aggregate(),
UserID: s.UserFactor.UserID,
UserResourceOwner: s.UserFactor.ResourceOwner,
TriggeredAtOrigin: http_util.DomainContext(ctx).Origin(),
EventType: e.EventType,
NotificationType: domain.NotificationTypeSms,
MessageType: domain.VerifySMSOTPMessageType,
Code: e.Code,
CodeExpiry: e.Expiry,
IsOTP: true,
Args: args,
},
queue.WithQueueName(notification.QueueName),
queue.WithMaxAttempts(u.maxAttempts),
)
}), nil
}
func (u *userNotifier) reduceOTPEmailCodeAdded(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*user.HumanOTPEmailCodeAddedEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-JL3hw", "reduce.wrong.event.type %s", user.HumanOTPEmailCodeAddedType)
}
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
ctx = HandlerContext(ctx, event.Aggregate())
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
user.HumanOTPEmailCodeAddedType,
user.HumanOTPEmailCodeSentType)
if err != nil {
return err
}
if alreadyHandled {
return nil
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
origin := http_util.DomainContext(ctx).Origin()
var authRequestID string
if e.AuthRequestInfo != nil {
authRequestID = e.AuthRequestInfo.ID
}
args := otpArgs(ctx, e.Expiry)
args.AuthRequestID = authRequestID
return u.queue.Insert(ctx,
&notification.Request{
Aggregate: e.Aggregate(),
UserID: e.Aggregate().ID,
UserResourceOwner: e.Aggregate().ResourceOwner,
TriggeredAtOrigin: origin,
EventType: e.EventType,
NotificationType: domain.NotificationTypeEmail,
MessageType: domain.VerifyEmailOTPMessageType,
Code: e.Code,
CodeExpiry: e.Expiry,
IsOTP: true,
URLTemplate: login.OTPLinkTemplate(origin, authRequestID, domain.MFATypeOTPEmail),
Args: args,
},
queue.WithQueueName(notification.QueueName),
queue.WithMaxAttempts(u.maxAttempts),
)
}), nil
}
func (u *userNotifier) reduceSessionOTPEmailChallenged(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*session.OTPEmailChallengedEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-zbsgt", "reduce.wrong.event.type %s", session.OTPEmailChallengedType)
}
if e.ReturnCode {
return handler.NewNoOpStatement(e), nil
}
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
ctx = HandlerContext(ctx, event.Aggregate())
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
session.OTPEmailChallengedType,
session.OTPEmailSentType)
if err != nil {
return err
}
if alreadyHandled {
return nil
}
s, err := u.queries.SessionByID(ctx, true, e.Aggregate().ID, "", nil)
if err != nil {
return err
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
origin := http_util.DomainContext(ctx).Origin()
args := otpArgs(ctx, e.Expiry)
args.SessionID = e.Aggregate().ID
return u.queue.Insert(ctx,
&notification.Request{
Aggregate: e.Aggregate(),
UserID: s.UserFactor.UserID,
UserResourceOwner: s.UserFactor.ResourceOwner,
TriggeredAtOrigin: origin,
EventType: e.EventType,
NotificationType: domain.NotificationTypeEmail,
MessageType: domain.VerifyEmailOTPMessageType,
Code: e.Code,
CodeExpiry: e.Expiry,
IsOTP: true,
URLTemplate: u.otpEmailTemplate(origin, e),
Args: args,
},
queue.WithQueueName(notification.QueueName),
queue.WithMaxAttempts(u.maxAttempts),
)
}), nil
}
func (u *userNotifier) otpEmailTemplate(origin string, e *session.OTPEmailChallengedEvent) string {
if e.URLTmpl != "" {
return e.URLTmpl
}
return origin + u.otpEmailTmpl
}
func otpArgs(ctx context.Context, expiry time.Duration) *domain.NotificationArguments {
domainCtx := http_util.DomainContext(ctx)
return &domain.NotificationArguments{
Origin: domainCtx.Origin(),
Domain: domainCtx.RequestedDomain(),
Expiry: expiry,
}
}
func (u *userNotifier) reduceDomainClaimed(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*user.DomainClaimedEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Drh5w", "reduce.wrong.event.type %s", user.UserDomainClaimedType)
}
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
ctx = HandlerContext(ctx, event.Aggregate())
alreadyHandled, err := u.queries.IsAlreadyHandled(ctx, event, nil,
user.UserDomainClaimedType, user.UserDomainClaimedSentType)
if err != nil {
return err
}
if alreadyHandled {
return nil
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
origin := http_util.DomainContext(ctx).Origin()
return u.queue.Insert(ctx,
&notification.Request{
Aggregate: e.Aggregate(),
UserID: e.Aggregate().ID,
UserResourceOwner: e.Aggregate().ResourceOwner,
TriggeredAtOrigin: origin,
EventType: e.EventType,
NotificationType: domain.NotificationTypeEmail,
MessageType: domain.DomainClaimedMessageType,
URLTemplate: login.LoginLink(origin, e.Aggregate().ResourceOwner),
UnverifiedNotificationChannel: true,
Args: &domain.NotificationArguments{
TempUsername: e.UserName,
},
RequiresPreviousDomain: true,
},
queue.WithQueueName(notification.QueueName),
queue.WithMaxAttempts(u.maxAttempts),
)
}), nil
}
func (u *userNotifier) reducePasswordlessCodeRequested(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*user.HumanPasswordlessInitCodeRequestedEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-EDtjd", "reduce.wrong.event.type %s", user.HumanPasswordlessInitCodeAddedType)
}
if e.CodeReturned {
return handler.NewNoOpStatement(e), nil
}
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
ctx = HandlerContext(ctx, event.Aggregate())
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, map[string]interface{}{"id": e.ID}, user.HumanPasswordlessInitCodeSentType)
if err != nil {
return err
}
if alreadyHandled {
return nil
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
origin := http_util.DomainContext(ctx).Origin()
return u.queue.Insert(ctx,
&notification.Request{
Aggregate: e.Aggregate(),
UserID: e.Aggregate().ID,
UserResourceOwner: e.Aggregate().ResourceOwner,
TriggeredAtOrigin: origin,
EventType: e.EventType,
NotificationType: domain.NotificationTypeEmail,
MessageType: domain.PasswordlessRegistrationMessageType,
URLTemplate: u.passwordlessCodeTemplate(origin, e),
Args: &domain.NotificationArguments{
CodeID: e.ID,
},
CodeExpiry: e.Expiry,
Code: e.Code,
},
queue.WithQueueName(notification.QueueName),
queue.WithMaxAttempts(u.maxAttempts),
)
}), nil
}
func (u *userNotifier) passwordlessCodeTemplate(origin string, e *user.HumanPasswordlessInitCodeRequestedEvent) string {
if e.URLTemplate != "" {
return e.URLTemplate
}
return domain.PasswordlessInitCodeLinkTemplate(origin+login.HandlerPrefix+login.EndpointPasswordlessRegistration, e.Aggregate().ID, e.Aggregate().ResourceOwner, e.ID)
}
func (u *userNotifier) reducePasswordChanged(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*user.HumanPasswordChangedEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Yko2z8", "reduce.wrong.event.type %s", user.HumanPasswordChangedType)
}
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
ctx = HandlerContext(ctx, event.Aggregate())
alreadyHandled, err := u.queries.IsAlreadyHandled(ctx, event, nil, user.HumanPasswordChangeSentType)
if err != nil {
return err
}
if alreadyHandled {
return nil
}
notificationPolicy, err := u.queries.NotificationPolicyByOrg(ctx, true, e.Aggregate().ResourceOwner, false)
if err != nil && !zerrors.IsNotFound(err) {
return err
}
if !notificationPolicy.PasswordChange {
return nil
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
origin := http_util.DomainContext(ctx).Origin()
return u.queue.Insert(ctx,
&notification.Request{
Aggregate: e.Aggregate(),
UserID: e.Aggregate().ID,
UserResourceOwner: e.Aggregate().ResourceOwner,
TriggeredAtOrigin: origin,
EventType: e.EventType,
NotificationType: domain.NotificationTypeEmail,
MessageType: domain.PasswordChangeMessageType,
URLTemplate: console.LoginHintLink(origin, "{{.PreferredLoginName}}"),
UnverifiedNotificationChannel: true,
},
queue.WithQueueName(notification.QueueName),
queue.WithMaxAttempts(u.maxAttempts),
)
}), nil
}
func (u *userNotifier) reducePhoneCodeAdded(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*user.HumanPhoneCodeAddedEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-He83g", "reduce.wrong.event.type %s", user.HumanPhoneCodeAddedType)
}
if e.CodeReturned {
return handler.NewNoOpStatement(e), nil
}
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
ctx = HandlerContext(ctx, event.Aggregate())
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
user.UserV1PhoneCodeAddedType, user.UserV1PhoneCodeSentType,
user.HumanPhoneCodeAddedType, user.HumanPhoneCodeSentType)
if err != nil {
return err
}
if alreadyHandled {
return nil
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
return u.queue.Insert(ctx,
&notification.Request{
Aggregate: e.Aggregate(),
UserID: e.Aggregate().ID,
UserResourceOwner: e.Aggregate().ResourceOwner,
TriggeredAtOrigin: http_util.DomainContext(ctx).Origin(),
EventType: e.EventType,
NotificationType: domain.NotificationTypeSms,
MessageType: domain.VerifyPhoneMessageType,
CodeExpiry: e.Expiry,
Code: e.Code,
UnverifiedNotificationChannel: true,
Args: &domain.NotificationArguments{
Domain: http_util.DomainContext(ctx).RequestedDomain(),
},
},
queue.WithQueueName(notification.QueueName),
queue.WithMaxAttempts(u.maxAttempts),
)
}), nil
}
func (u *userNotifier) reduceInviteCodeAdded(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*user.HumanInviteCodeAddedEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Eeg3s", "reduce.wrong.event.type %s", user.HumanInviteCodeAddedType)
}
if e.CodeReturned {
return handler.NewNoOpStatement(e), nil
}
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
ctx = HandlerContext(ctx, event.Aggregate())
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
user.HumanInviteCodeAddedType, user.HumanInviteCodeSentType)
if err != nil {
return err
}
if alreadyHandled {
return nil
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
origin := http_util.DomainContext(ctx).Origin()
applicationName := e.ApplicationName
if applicationName == "" {
applicationName = "ZITADEL"
}
return u.queue.Insert(ctx,
&notification.Request{
Aggregate: e.Aggregate(),
UserID: e.Aggregate().ID,
UserResourceOwner: e.Aggregate().ResourceOwner,
TriggeredAtOrigin: origin,
EventType: e.EventType,
NotificationType: domain.NotificationTypeEmail,
MessageType: domain.InviteUserMessageType,
CodeExpiry: e.Expiry,
Code: e.Code,
UnverifiedNotificationChannel: true,
URLTemplate: u.inviteCodeTemplate(origin, e),
Args: &domain.NotificationArguments{
AuthRequestID: e.AuthRequestID,
ApplicationName: applicationName,
},
},
queue.WithQueueName(notification.QueueName),
queue.WithMaxAttempts(u.maxAttempts),
)
}), nil
}
func (u *userNotifier) inviteCodeTemplate(origin string, e *user.HumanInviteCodeAddedEvent) string {
if e.URLTemplate != "" {
return e.URLTemplate
}
return login.InviteUserLinkTemplate(origin, e.Aggregate().ID, e.Aggregate().ResourceOwner, e.AuthRequestID)
}
func (u *userNotifier) checkIfCodeAlreadyHandledOrExpired(ctx context.Context, event eventstore.Event, expiry time.Duration, data map[string]interface{}, eventTypes ...eventstore.EventType) (bool, error) {
if expiry > 0 && event.CreatedAt().Add(expiry).Before(time.Now().UTC()) {
return true, nil
}
return u.queries.IsAlreadyHandled(ctx, event, data, eventTypes...)
}

View File

@@ -0,0 +1,835 @@
package handlers
import (
"context"
"errors"
"strings"
"time"
http_util "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/api/ui/login"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/handler/v2"
"github.com/zitadel/zitadel/internal/notification/channels"
"github.com/zitadel/zitadel/internal/notification/senders"
"github.com/zitadel/zitadel/internal/notification/types"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/repository/session"
"github.com/zitadel/zitadel/internal/repository/user"
"github.com/zitadel/zitadel/internal/zerrors"
)
type userNotifierLegacy struct {
commands Commands
queries *NotificationQueries
channels types.ChannelChains
otpEmailTmpl string
}
func NewUserNotifierLegacy(
ctx context.Context,
config handler.Config,
commands Commands,
queries *NotificationQueries,
channels types.ChannelChains,
otpEmailTmpl string,
) *handler.Handler {
return handler.NewHandler(ctx, &config, &userNotifierLegacy{
commands: commands,
queries: queries,
otpEmailTmpl: otpEmailTmpl,
channels: channels,
})
}
func (u *userNotifierLegacy) Name() string {
return UserNotificationsProjectionTable
}
func (u *userNotifierLegacy) Reducers() []handler.AggregateReducer {
return []handler.AggregateReducer{
{
Aggregate: user.AggregateType,
EventReducers: []handler.EventReducer{
{
Event: user.UserV1InitialCodeAddedType,
Reduce: u.reduceInitCodeAdded,
},
{
Event: user.HumanInitialCodeAddedType,
Reduce: u.reduceInitCodeAdded,
},
{
Event: user.UserV1EmailCodeAddedType,
Reduce: u.reduceEmailCodeAdded,
},
{
Event: user.HumanEmailCodeAddedType,
Reduce: u.reduceEmailCodeAdded,
},
{
Event: user.UserV1PasswordCodeAddedType,
Reduce: u.reducePasswordCodeAdded,
},
{
Event: user.HumanPasswordCodeAddedType,
Reduce: u.reducePasswordCodeAdded,
},
{
Event: user.UserDomainClaimedType,
Reduce: u.reduceDomainClaimed,
},
{
Event: user.HumanPasswordlessInitCodeRequestedType,
Reduce: u.reducePasswordlessCodeRequested,
},
{
Event: user.UserV1PhoneCodeAddedType,
Reduce: u.reducePhoneCodeAdded,
},
{
Event: user.HumanPhoneCodeAddedType,
Reduce: u.reducePhoneCodeAdded,
},
{
Event: user.HumanPasswordChangedType,
Reduce: u.reducePasswordChanged,
},
{
Event: user.HumanOTPSMSCodeAddedType,
Reduce: u.reduceOTPSMSCodeAdded,
},
{
Event: user.HumanOTPEmailCodeAddedType,
Reduce: u.reduceOTPEmailCodeAdded,
},
{
Event: user.HumanInviteCodeAddedType,
Reduce: u.reduceInviteCodeAdded,
},
},
},
{
Aggregate: session.AggregateType,
EventReducers: []handler.EventReducer{
{
Event: session.OTPSMSChallengedType,
Reduce: u.reduceSessionOTPSMSChallenged,
},
{
Event: session.OTPEmailChallengedType,
Reduce: u.reduceSessionOTPEmailChallenged,
},
},
},
}
}
func (u *userNotifierLegacy) reduceInitCodeAdded(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*user.HumanInitialCodeAddedEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-EFe2f", "reduce.wrong.event.type %s", user.HumanInitialCodeAddedType)
}
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
ctx = HandlerContext(ctx, event.Aggregate())
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
user.UserV1InitialCodeAddedType, user.UserV1InitialCodeSentType,
user.HumanInitialCodeAddedType, user.HumanInitialCodeSentType)
if err != nil {
return err
}
if alreadyHandled {
return nil
}
code, err := crypto.DecryptString(e.Code, u.queries.UserDataCrypto)
if err != nil {
return err
}
colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false)
if err != nil {
return err
}
template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false)
if err != nil {
return err
}
notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID)
if err != nil {
return err
}
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.InitCodeMessageType)
if err != nil {
return err
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e.Type()).
SendUserInitCode(ctx, notifyUser, code, e.AuthRequestID)
if err != nil {
if errors.Is(err, &channels.CancelError{}) {
// if the notification was canceled, we don't want to return the error, so there is no retry
return nil
}
return err
}
return u.commands.HumanInitCodeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID)
}), nil
}
func (u *userNotifierLegacy) reduceEmailCodeAdded(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*user.HumanEmailCodeAddedEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-SWf3g", "reduce.wrong.event.type %s", user.HumanEmailCodeAddedType)
}
if e.CodeReturned {
return handler.NewNoOpStatement(e), nil
}
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
ctx = HandlerContext(ctx, event.Aggregate())
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
user.UserV1EmailCodeAddedType, user.UserV1EmailCodeSentType,
user.HumanEmailCodeAddedType, user.HumanEmailCodeSentType)
if err != nil {
return err
}
if alreadyHandled {
return nil
}
code, err := crypto.DecryptString(e.Code, u.queries.UserDataCrypto)
if err != nil {
return err
}
colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false)
if err != nil {
return err
}
template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false)
if err != nil {
return err
}
notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID)
if err != nil {
return err
}
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.VerifyEmailMessageType)
if err != nil {
return err
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, event.Type()).
SendEmailVerificationCode(ctx, notifyUser, code, e.URLTemplate, e.AuthRequestID)
if err != nil {
if errors.Is(err, &channels.CancelError{}) {
// if the notification was canceled, we don't want to return the error, so there is no retry
return nil
}
return err
}
return u.commands.HumanEmailVerificationCodeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID)
}), nil
}
func (u *userNotifierLegacy) reducePasswordCodeAdded(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*user.HumanPasswordCodeAddedEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Eeg3s", "reduce.wrong.event.type %s", user.HumanPasswordCodeAddedType)
}
if e.CodeReturned {
return handler.NewNoOpStatement(e), nil
}
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
ctx = HandlerContext(ctx, event.Aggregate())
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
user.UserV1PasswordCodeAddedType, user.UserV1PasswordCodeSentType,
user.HumanPasswordCodeAddedType, user.HumanPasswordCodeSentType)
if err != nil {
return err
}
if alreadyHandled {
return nil
}
var code string
if e.Code != nil {
code, err = crypto.DecryptString(e.Code, u.queries.UserDataCrypto)
if err != nil {
return err
}
}
colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false)
if err != nil {
return err
}
template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false)
if err != nil {
return err
}
notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID)
if err != nil {
return err
}
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.PasswordResetMessageType)
if err != nil {
return err
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
generatorInfo := new(senders.CodeGeneratorInfo)
notify := types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, event.Type())
if e.NotificationType == domain.NotificationTypeSms {
notify = types.SendSMS(ctx, u.channels, translator, notifyUser, colors, e.Type(), e.Aggregate().InstanceID, e.ID, generatorInfo)
}
err = notify.SendPasswordCode(ctx, notifyUser, code, e.URLTemplate, e.AuthRequestID)
if err != nil {
if errors.Is(err, &channels.CancelError{}) {
// if the notification was canceled, we don't want to return the error, so there is no retry
return nil
}
return err
}
return u.commands.PasswordCodeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID, generatorInfo)
}), nil
}
func (u *userNotifierLegacy) reduceOTPSMSCodeAdded(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*user.HumanOTPSMSCodeAddedEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-ASF3g", "reduce.wrong.event.type %s", user.HumanOTPSMSCodeAddedType)
}
return u.reduceOTPSMS(
e,
e.Code,
e.Expiry,
e.Aggregate().ID,
e.Aggregate().ResourceOwner,
u.commands.HumanOTPSMSCodeSent,
user.HumanOTPSMSCodeAddedType,
user.HumanOTPSMSCodeSentType,
)
}
func (u *userNotifierLegacy) reduceSessionOTPSMSChallenged(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*session.OTPSMSChallengedEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Sk32L", "reduce.wrong.event.type %s", session.OTPSMSChallengedType)
}
if e.CodeReturned {
return handler.NewNoOpStatement(e), nil
}
ctx := HandlerContext(context.Background(), event.Aggregate())
s, err := u.queries.SessionByID(ctx, true, e.Aggregate().ID, "", nil)
if err != nil {
return nil, err
}
return u.reduceOTPSMS(
e,
e.Code,
e.Expiry,
s.UserFactor.UserID,
s.UserFactor.ResourceOwner,
u.commands.OTPSMSSent,
session.OTPSMSChallengedType,
session.OTPSMSSentType,
)
}
func (u *userNotifierLegacy) reduceOTPSMS(
event eventstore.Event,
code *crypto.CryptoValue,
expiry time.Duration,
userID,
resourceOwner string,
sentCommand func(ctx context.Context, userID, resourceOwner string, generatorInfo *senders.CodeGeneratorInfo) (err error),
eventTypes ...eventstore.EventType,
) (*handler.Statement, error) {
ctx := HandlerContext(context.Background(), event.Aggregate())
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, expiry, nil, eventTypes...)
if err != nil {
return nil, err
}
if alreadyHandled {
return handler.NewNoOpStatement(event), nil
}
var plainCode string
if code != nil {
plainCode, err = crypto.DecryptString(code, u.queries.UserDataCrypto)
if err != nil {
return nil, err
}
}
colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, resourceOwner, false)
if err != nil {
return nil, err
}
notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, userID)
if err != nil {
return nil, err
}
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.VerifySMSOTPMessageType)
if err != nil {
return nil, err
}
ctx, err = u.queries.Origin(ctx, event)
if err != nil {
return nil, err
}
generatorInfo := new(senders.CodeGeneratorInfo)
notify := types.SendSMS(ctx, u.channels, translator, notifyUser, colors, event.Type(), event.Aggregate().InstanceID, event.Aggregate().ID, generatorInfo)
err = notify.SendOTPSMSCode(ctx, plainCode, expiry)
if err != nil {
if errors.Is(err, &channels.CancelError{}) {
// if the notification was canceled, we don't want to return the error, so there is no retry
return handler.NewNoOpStatement(event), nil
}
return nil, err
}
err = sentCommand(ctx, event.Aggregate().ID, event.Aggregate().ResourceOwner, generatorInfo)
if err != nil {
return nil, err
}
return handler.NewNoOpStatement(event), nil
}
func (u *userNotifierLegacy) reduceOTPEmailCodeAdded(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*user.HumanOTPEmailCodeAddedEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-JL3hw", "reduce.wrong.event.type %s", user.HumanOTPEmailCodeAddedType)
}
var authRequestID string
if e.AuthRequestInfo != nil {
authRequestID = e.AuthRequestInfo.ID
}
url := func(code, origin string, _ *query.NotifyUser) (string, error) {
return login.OTPLink(origin, authRequestID, code, domain.MFATypeOTPEmail), nil
}
return u.reduceOTPEmail(
e,
e.Code,
e.Expiry,
e.Aggregate().ID,
e.Aggregate().ResourceOwner,
url,
u.commands.HumanOTPEmailCodeSent,
user.HumanOTPEmailCodeAddedType,
user.HumanOTPEmailCodeSentType,
)
}
func (u *userNotifierLegacy) reduceSessionOTPEmailChallenged(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*session.OTPEmailChallengedEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-zbsgt", "reduce.wrong.event.type %s", session.OTPEmailChallengedType)
}
if e.ReturnCode {
return handler.NewNoOpStatement(e), nil
}
ctx := HandlerContext(context.Background(), event.Aggregate())
s, err := u.queries.SessionByID(ctx, true, e.Aggregate().ID, "", nil)
if err != nil {
return nil, err
}
url := func(code, origin string, user *query.NotifyUser) (string, error) {
var buf strings.Builder
urlTmpl := origin + u.otpEmailTmpl
if e.URLTmpl != "" {
urlTmpl = e.URLTmpl
}
if err := domain.RenderOTPEmailURLTemplate(&buf, urlTmpl, code, user.ID, user.PreferredLoginName, user.DisplayName, e.Aggregate().ID, user.PreferredLanguage); err != nil {
return "", err
}
return buf.String(), nil
}
return u.reduceOTPEmail(
e,
e.Code,
e.Expiry,
s.UserFactor.UserID,
s.UserFactor.ResourceOwner,
url,
u.commands.OTPEmailSent,
user.HumanOTPEmailCodeAddedType,
user.HumanOTPEmailCodeSentType,
)
}
func (u *userNotifierLegacy) reduceOTPEmail(
event eventstore.Event,
code *crypto.CryptoValue,
expiry time.Duration,
userID,
resourceOwner string,
urlTmpl func(code, origin string, user *query.NotifyUser) (string, error),
sentCommand func(ctx context.Context, userID string, resourceOwner string) (err error),
eventTypes ...eventstore.EventType,
) (*handler.Statement, error) {
ctx := HandlerContext(context.Background(), event.Aggregate())
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, expiry, nil, eventTypes...)
if err != nil {
return nil, err
}
if alreadyHandled {
return handler.NewNoOpStatement(event), nil
}
plainCode, err := crypto.DecryptString(code, u.queries.UserDataCrypto)
if err != nil {
return nil, err
}
colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, resourceOwner, false)
if err != nil {
return nil, err
}
template, err := u.queries.MailTemplateByOrg(ctx, resourceOwner, false)
if err != nil {
return nil, err
}
notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, userID)
if err != nil {
return nil, err
}
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, resourceOwner, domain.VerifyEmailOTPMessageType)
if err != nil {
return nil, err
}
ctx, err = u.queries.Origin(ctx, event)
if err != nil {
return nil, err
}
url, err := urlTmpl(plainCode, http_util.DomainContext(ctx).Origin(), notifyUser)
if err != nil {
return nil, err
}
notify := types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, event.Type())
err = notify.SendOTPEmailCode(ctx, url, plainCode, expiry)
if err != nil {
if errors.Is(err, &channels.CancelError{}) {
// if the notification was canceled, we don't want to return the error, so there is no retry
return handler.NewNoOpStatement(event), nil
}
return nil, err
}
err = sentCommand(ctx, event.Aggregate().ID, event.Aggregate().ResourceOwner)
if err != nil {
return nil, err
}
return handler.NewNoOpStatement(event), nil
}
func (u *userNotifierLegacy) reduceDomainClaimed(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*user.DomainClaimedEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Drh5w", "reduce.wrong.event.type %s", user.UserDomainClaimedType)
}
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
ctx = HandlerContext(ctx, event.Aggregate())
alreadyHandled, err := u.queries.IsAlreadyHandled(ctx, event, nil,
user.UserDomainClaimedType, user.UserDomainClaimedSentType)
if err != nil {
return err
}
if alreadyHandled {
return nil
}
colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false)
if err != nil {
return err
}
template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false)
if err != nil {
return err
}
notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID)
if err != nil {
return err
}
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.DomainClaimedMessageType)
if err != nil {
return err
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, event.Type()).
SendDomainClaimed(ctx, notifyUser, e.UserName)
if err != nil {
if errors.Is(err, &channels.CancelError{}) {
// if the notification was canceled, we don't want to return the error, so there is no retry
return nil
}
return err
}
return u.commands.UserDomainClaimedSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID)
}), nil
}
func (u *userNotifierLegacy) reducePasswordlessCodeRequested(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*user.HumanPasswordlessInitCodeRequestedEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-EDtjd", "reduce.wrong.event.type %s", user.HumanPasswordlessInitCodeAddedType)
}
if e.CodeReturned {
return handler.NewNoOpStatement(e), nil
}
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
ctx = HandlerContext(ctx, event.Aggregate())
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, map[string]interface{}{"id": e.ID}, user.HumanPasswordlessInitCodeSentType)
if err != nil {
return err
}
if alreadyHandled {
return nil
}
code, err := crypto.DecryptString(e.Code, u.queries.UserDataCrypto)
if err != nil {
return err
}
colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false)
if err != nil {
return err
}
template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false)
if err != nil {
return err
}
notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID)
if err != nil {
return err
}
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.PasswordlessRegistrationMessageType)
if err != nil {
return err
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, event.Type()).
SendPasswordlessRegistrationLink(ctx, notifyUser, code, e.ID, e.URLTemplate)
if err != nil {
if errors.Is(err, &channels.CancelError{}) {
// if the notification was canceled, we don't want to return the error, so there is no retry
return nil
}
return err
}
return u.commands.HumanPasswordlessInitCodeSent(ctx, e.Aggregate().ID, e.Aggregate().ResourceOwner, e.ID)
}), nil
}
func (u *userNotifierLegacy) reducePasswordChanged(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*user.HumanPasswordChangedEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Yko2z8", "reduce.wrong.event.type %s", user.HumanPasswordChangedType)
}
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
ctx = HandlerContext(ctx, event.Aggregate())
alreadyHandled, err := u.queries.IsAlreadyHandled(ctx, event, nil, user.HumanPasswordChangeSentType)
if err != nil {
return err
}
if alreadyHandled {
return nil
}
notificationPolicy, err := u.queries.NotificationPolicyByOrg(ctx, true, e.Aggregate().ResourceOwner, false)
if zerrors.IsNotFound(err) {
return nil
}
if err != nil {
return err
}
if !notificationPolicy.PasswordChange {
return nil
}
colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false)
if err != nil {
return err
}
template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false)
if err != nil {
return err
}
notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID)
if err != nil {
return err
}
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.PasswordChangeMessageType)
if err != nil {
return err
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, event.Type()).
SendPasswordChange(ctx, notifyUser)
if err != nil {
if errors.Is(err, &channels.CancelError{}) {
// if the notification was canceled, we don't want to return the error, so there is no retry
return nil
}
return err
}
return u.commands.PasswordChangeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID)
}), nil
}
func (u *userNotifierLegacy) reducePhoneCodeAdded(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*user.HumanPhoneCodeAddedEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-He83g", "reduce.wrong.event.type %s", user.HumanPhoneCodeAddedType)
}
if e.CodeReturned {
return handler.NewNoOpStatement(e), nil
}
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
ctx = HandlerContext(ctx, event.Aggregate())
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
user.UserV1PhoneCodeAddedType, user.UserV1PhoneCodeSentType,
user.HumanPhoneCodeAddedType, user.HumanPhoneCodeSentType)
if err != nil {
return err
}
if alreadyHandled {
return nil
}
var code string
if e.Code != nil {
code, err = crypto.DecryptString(e.Code, u.queries.UserDataCrypto)
if err != nil {
return err
}
}
colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false)
if err != nil {
return err
}
notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID)
if err != nil {
return err
}
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.VerifyPhoneMessageType)
if err != nil {
return err
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
generatorInfo := new(senders.CodeGeneratorInfo)
if err = types.SendSMS(ctx, u.channels, translator, notifyUser, colors, e.Type(), e.Aggregate().InstanceID, e.ID, generatorInfo).
SendPhoneVerificationCode(ctx, code); err != nil {
if errors.Is(err, &channels.CancelError{}) {
// if the notification was canceled, we don't want to return the error, so there is no retry
return nil
}
return err
}
return u.commands.HumanPhoneVerificationCodeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID, generatorInfo)
}), nil
}
func (u *userNotifierLegacy) reduceInviteCodeAdded(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*user.HumanInviteCodeAddedEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Eeg3s", "reduce.wrong.event.type %s", user.HumanInviteCodeAddedType)
}
if e.CodeReturned {
return handler.NewNoOpStatement(e), nil
}
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
ctx = HandlerContext(ctx, event.Aggregate())
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
user.HumanInviteCodeAddedType, user.HumanInviteCodeSentType)
if err != nil {
return err
}
if alreadyHandled {
return nil
}
code, err := crypto.DecryptString(e.Code, u.queries.UserDataCrypto)
if err != nil {
return err
}
colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false)
if err != nil {
return err
}
template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false)
if err != nil {
return err
}
notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID)
if err != nil {
return err
}
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.InviteUserMessageType)
if err != nil {
return err
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
notify := types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, event.Type())
err = notify.SendInviteCode(ctx, notifyUser, code, e.ApplicationName, e.URLTemplate, e.AuthRequestID)
if err != nil {
if errors.Is(err, &channels.CancelError{}) {
// if the notification was canceled, we don't want to return the error, so there is no retry
return nil
}
return err
}
return u.commands.InviteCodeSent(ctx, e.Aggregate().ID, e.Aggregate().ResourceOwner)
}), nil
}
func (u *userNotifierLegacy) checkIfCodeAlreadyHandledOrExpired(ctx context.Context, event eventstore.Event, expiry time.Duration, data map[string]interface{}, eventTypes ...eventstore.EventType) (bool, error) {
if expiry > 0 && event.CreatedAt().Add(expiry).Before(time.Now().UTC()) {
return true, nil
}
return u.queries.IsAlreadyHandled(ctx, event, data, eventTypes...)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,75 @@
package messages
import (
"fmt"
"mime"
"regexp"
"strings"
"time"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/notification/channels"
)
var (
isHTMLRgx = regexp.MustCompile(`.*<html.*>.*`)
lineBreak = "\r\n"
)
var _ channels.Message = (*Email)(nil)
type Email struct {
Recipients []string
BCC []string
CC []string
SenderEmail string
SenderName string
ReplyToAddress string
Subject string
Content string
TriggeringEventType eventstore.EventType
}
func (msg *Email) GetContent() (string, error) {
headers := make(map[string]string)
from := msg.SenderEmail
if msg.SenderName != "" {
from = fmt.Sprintf("%s <%s>", bEncodeWord(msg.SenderName), msg.SenderEmail)
}
headers["From"] = from
if msg.ReplyToAddress != "" {
headers["Reply-to"] = msg.ReplyToAddress
}
headers["Return-Path"] = msg.SenderEmail
headers["To"] = strings.Join(msg.Recipients, ", ")
headers["Cc"] = strings.Join(msg.CC, ", ")
headers["Date"] = time.Now().Format(time.RFC1123Z)
message := ""
for k, v := range headers {
message += fmt.Sprintf("%s: %s"+lineBreak, k, v)
}
//default mime-type is html
mime := "MIME-Version: 1.0" + lineBreak + "Content-Type: text/html; charset=\"UTF-8\"" + lineBreak + lineBreak
if !isHTML(msg.Content) {
mime = "MIME-Version: 1.0" + lineBreak + "Content-Type: text/plain; charset=\"UTF-8\"" + lineBreak + lineBreak
}
subject := "Subject: " + bEncodeWord(msg.Subject) + lineBreak
message += subject + mime + lineBreak + msg.Content
return message, nil
}
func (msg *Email) GetTriggeringEventType() eventstore.EventType {
return msg.TriggeringEventType
}
func isHTML(input string) bool {
return isHTMLRgx.MatchString(input)
}
// returns a RFC1342 "B" encoded string to allow non-ascii characters
func bEncodeWord(word string) string {
return mime.BEncoding.Encode("UTF-8", word)
}

View File

@@ -0,0 +1,27 @@
package messages
import (
"net/url"
"github.com/zitadel/schema"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/notification/channels"
)
var _ channels.Message = (*Form)(nil)
type Form struct {
Serializable any
TriggeringEventType eventstore.EventType
}
func (msg *Form) GetContent() (string, error) {
values := make(url.Values)
err := schema.NewEncoder().Encode(msg.Serializable, values)
return values.Encode(), err
}
func (msg *Form) GetTriggeringEventType() eventstore.EventType {
return msg.TriggeringEventType
}

View File

@@ -0,0 +1,24 @@
package messages
import (
"encoding/json"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/notification/channels"
)
var _ channels.Message = (*JSON)(nil)
type JSON struct {
Serializable interface{}
TriggeringEventType eventstore.EventType
}
func (msg *JSON) GetContent() (string, error) {
bytes, err := json.Marshal(msg.Serializable)
return string(bytes), err
}
func (msg *JSON) GetTriggeringEventType() eventstore.EventType {
return msg.TriggeringEventType
}

View File

@@ -0,0 +1,29 @@
package messages
import (
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/notification/channels"
)
var _ channels.Message = (*SMS)(nil)
type SMS struct {
SenderPhoneNumber string
RecipientPhoneNumber string
Content string
TriggeringEventType eventstore.EventType
// VerificationID is set by the sender
VerificationID *string
InstanceID string
JobID string
UserID string
}
func (msg *SMS) GetContent() (string, error) {
return msg.Content, nil
}
func (msg *SMS) GetTriggeringEventType() eventstore.EventType {
return msg.TriggeringEventType
}

View File

@@ -0,0 +1,111 @@
package notification
import (
"context"
"fmt"
"time"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/handler/v2"
"github.com/zitadel/zitadel/internal/notification/handlers"
_ "github.com/zitadel/zitadel/internal/notification/statik"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/query/projection"
"github.com/zitadel/zitadel/internal/queue"
)
var (
projections []*handler.Handler
)
func Register(
ctx context.Context,
userHandlerCustomConfig, quotaHandlerCustomConfig, telemetryHandlerCustomConfig, backChannelLogoutHandlerCustomConfig projection.CustomConfig,
notificationWorkerConfig handlers.WorkerConfig,
telemetryCfg handlers.TelemetryPusherConfig,
externalDomain string,
externalPort uint16,
externalSecure bool,
commands *command.Commands,
queries *query.Queries,
es *eventstore.Eventstore,
otpEmailTmpl, fileSystemPath string,
userEncryption, smtpEncryption, smsEncryption, keysEncryptionAlg crypto.EncryptionAlgorithm,
tokenLifetime time.Duration,
queue *queue.Queue,
) {
if !notificationWorkerConfig.LegacyEnabled {
queue.ShouldStart()
}
// make sure the slice does not contain old values
projections = nil
q := handlers.NewNotificationQueries(queries, es, externalDomain, externalPort, externalSecure, fileSystemPath, userEncryption, smtpEncryption, smsEncryption)
c := newChannels(q)
projections = append(projections, handlers.NewUserNotifier(ctx, projection.ApplyCustomConfig(userHandlerCustomConfig), commands, q, c, otpEmailTmpl, notificationWorkerConfig, queue))
projections = append(projections, handlers.NewQuotaNotifier(ctx, projection.ApplyCustomConfig(quotaHandlerCustomConfig), commands, q, c))
projections = append(projections, handlers.NewBackChannelLogoutNotifier(
ctx,
projection.ApplyCustomConfig(backChannelLogoutHandlerCustomConfig),
commands,
q,
es,
keysEncryptionAlg,
c,
tokenLifetime,
))
if telemetryCfg.Enabled {
projections = append(projections, handlers.NewTelemetryPusher(ctx, telemetryCfg, projection.ApplyCustomConfig(telemetryHandlerCustomConfig), commands, q, c))
}
if !notificationWorkerConfig.LegacyEnabled {
queue.AddWorkers(handlers.NewNotificationWorker(notificationWorkerConfig, commands, q, c))
}
}
func Start(ctx context.Context) {
for _, projection := range projections {
projection.Start(ctx)
}
}
func SetCurrentState(ctx context.Context, es *eventstore.Eventstore) error {
if len(projections) == 0 {
return nil
}
position, err := es.LatestPosition(ctx, eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxPosition).InstanceID(authz.GetInstance(ctx).InstanceID()).OrderDesc().Limit(1))
if err != nil {
return err
}
for i, projection := range projections {
logging.WithFields("name", projection.ProjectionName(), "instance", authz.GetInstance(ctx).InstanceID(), "index", fmt.Sprintf("%d/%d", i, len(projections))).Info("set current state of notification projection")
_, err = projection.Trigger(ctx, handler.WithMinPosition(position))
if err != nil {
return err
}
logging.WithFields("name", projection.ProjectionName(), "instance", authz.GetInstance(ctx).InstanceID(), "index", fmt.Sprintf("%d/%d", i, len(projections))).Info("current state of notification projection set")
}
return nil
}
func ProjectInstance(ctx context.Context) error {
for i, projection := range projections {
logging.WithFields("name", projection.ProjectionName(), "instance", authz.GetInstance(ctx).InstanceID(), "index", fmt.Sprintf("%d/%d", i, len(projections))).Info("starting notification projection")
_, err := projection.Trigger(ctx)
if err != nil {
return err
}
logging.WithFields("name", projection.ProjectionName(), "instance", authz.GetInstance(ctx).InstanceID(), "index", fmt.Sprintf("%d/%d", i, len(projections))).Info("notification projection done")
}
return nil
}
func Projections() []*handler.Handler {
return projections
}

View File

@@ -0,0 +1,28 @@
package senders
import "github.com/zitadel/zitadel/internal/notification/channels"
var _ channels.NotificationChannel = (*Chain)(nil)
type Chain struct {
channels []channels.NotificationChannel
}
func ChainChannels(channel ...channels.NotificationChannel) *Chain {
return &Chain{channels: channel}
}
// HandleMessage returns a non nil error from a provider immediately if any occurs
// messages are sent to channels in the same order they were provided to ChainChannels()
func (c *Chain) HandleMessage(message channels.Message) error {
for i := range c.channels {
if err := c.channels[i].HandleMessage(message); err != nil {
return err
}
}
return nil
}
func (c *Chain) Len() int {
return len(c.channels)
}

View File

@@ -0,0 +1,24 @@
package senders
type CodeGenerator interface {
VerifyCode(verificationID, code string) error
}
type CodeGeneratorInfo struct {
ID string `json:"id,omitempty"`
VerificationID string `json:"verificationId,omitempty"`
}
func (c *CodeGeneratorInfo) GetID() string {
if c == nil {
return ""
}
return c.ID
}
func (c *CodeGeneratorInfo) GetVerificationID() string {
if c == nil {
return ""
}
return c.VerificationID
}

View File

@@ -0,0 +1,28 @@
package senders
import (
"context"
"github.com/zitadel/zitadel/internal/notification/channels"
"github.com/zitadel/zitadel/internal/notification/channels/fs"
"github.com/zitadel/zitadel/internal/notification/channels/log"
)
func debugChannels(ctx context.Context, getFileSystemProvider func(ctx context.Context) (*fs.Config, error), getLogProvider func(ctx context.Context) (*log.Config, error)) []channels.NotificationChannel {
var (
providers []channels.NotificationChannel
)
if fsProvider, err := getFileSystemProvider(ctx); err == nil {
p, err := fs.InitFSChannel(*fsProvider)
if err == nil {
providers = append(providers, p)
}
}
if logProvider, err := getLogProvider(ctx); err == nil {
providers = append(providers, log.InitStdoutChannel(*logProvider))
}
return providers
}

View File

@@ -0,0 +1,68 @@
package senders
import (
"context"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/notification/channels"
"github.com/zitadel/zitadel/internal/notification/channels/email"
"github.com/zitadel/zitadel/internal/notification/channels/fs"
"github.com/zitadel/zitadel/internal/notification/channels/instrumenting"
"github.com/zitadel/zitadel/internal/notification/channels/log"
"github.com/zitadel/zitadel/internal/notification/channels/smtp"
"github.com/zitadel/zitadel/internal/notification/channels/webhook"
)
const smtpSpanName = "smtp.NotificationChannel"
func EmailChannels(
ctx context.Context,
emailConfig *email.Config,
getFileSystemProvider func(ctx context.Context) (*fs.Config, error),
getLogProvider func(ctx context.Context) (*log.Config, error),
successMetricName,
failureMetricName string,
) (chain *Chain, err error) {
channels := make([]channels.NotificationChannel, 0, 3)
if emailConfig.SMTPConfig != nil {
p, err := smtp.InitChannel(emailConfig.SMTPConfig)
logging.WithFields(
"instance", authz.GetInstance(ctx).InstanceID(),
).OnError(err).Debug("initializing SMTP channel failed")
if err == nil {
channels = append(
channels,
instrumenting.Wrap(
ctx,
p,
smtpSpanName,
successMetricName,
failureMetricName,
),
)
}
}
if emailConfig.WebhookConfig != nil {
webhookChannel, err := webhook.InitChannel(ctx, *emailConfig.WebhookConfig)
logging.WithFields(
"instance", authz.GetInstance(ctx).InstanceID(),
"callurl", emailConfig.WebhookConfig.CallURL,
).OnError(err).Debug("initializing JSON channel failed")
if err == nil {
channels = append(
channels,
instrumenting.Wrap(
ctx,
webhookChannel,
webhookSpanName,
successMetricName,
failureMetricName,
),
)
}
}
channels = append(channels, debugChannels(ctx, getFileSystemProvider, getLogProvider)...)
return ChainChannels(channels...), nil
}

View File

@@ -0,0 +1,3 @@
package senders
//go:generate mockgen -package mock -destination ./mock/code_generator.mock.go github.com/zitadel/zitadel/internal/notification/senders CodeGenerator

View File

@@ -0,0 +1,53 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/zitadel/zitadel/internal/notification/senders (interfaces: CodeGenerator)
//
// Generated by this command:
//
// mockgen -package mock -destination ./mock/code_generator.mock.go github.com/zitadel/zitadel/internal/notification/senders CodeGenerator
//
// Package mock is a generated GoMock package.
package mock
import (
reflect "reflect"
gomock "go.uber.org/mock/gomock"
)
// MockCodeGenerator is a mock of CodeGenerator interface.
type MockCodeGenerator struct {
ctrl *gomock.Controller
recorder *MockCodeGeneratorMockRecorder
}
// MockCodeGeneratorMockRecorder is the mock recorder for MockCodeGenerator.
type MockCodeGeneratorMockRecorder struct {
mock *MockCodeGenerator
}
// NewMockCodeGenerator creates a new mock instance.
func NewMockCodeGenerator(ctrl *gomock.Controller) *MockCodeGenerator {
mock := &MockCodeGenerator{ctrl: ctrl}
mock.recorder = &MockCodeGeneratorMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockCodeGenerator) EXPECT() *MockCodeGeneratorMockRecorder {
return m.recorder
}
// VerifyCode mocks base method.
func (m *MockCodeGenerator) VerifyCode(arg0, arg1 string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "VerifyCode", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// VerifyCode indicates an expected call of VerifyCode.
func (mr *MockCodeGeneratorMockRecorder) VerifyCode(arg0, arg1 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifyCode", reflect.TypeOf((*MockCodeGenerator)(nil).VerifyCode), arg0, arg1)
}

View File

@@ -0,0 +1,49 @@
package senders
import (
"context"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/notification/channels"
"github.com/zitadel/zitadel/internal/notification/channels/fs"
"github.com/zitadel/zitadel/internal/notification/channels/instrumenting"
"github.com/zitadel/zitadel/internal/notification/channels/log"
"github.com/zitadel/zitadel/internal/notification/channels/set"
)
const setSpanName = "security_event_token.NotificationChannel"
func SecurityEventTokenChannels(
ctx context.Context,
setConfig set.Config,
getFileSystemProvider func(ctx context.Context) (*fs.Config, error),
getLogProvider func(ctx context.Context) (*log.Config, error),
successMetricName,
failureMetricName string,
) (*Chain, error) {
if err := setConfig.Validate(); err != nil {
return nil, err
}
channels := make([]channels.NotificationChannel, 0, 3)
setChannel, err := set.InitChannel(ctx, setConfig)
logging.WithFields(
"instance", authz.GetInstance(ctx).InstanceID(),
"callurl", setConfig.CallURL,
).OnError(err).Debug("initializing SET channel failed")
if err == nil {
channels = append(
channels,
instrumenting.Wrap(
ctx,
setChannel,
setSpanName,
successMetricName,
failureMetricName,
),
)
}
channels = append(channels, debugChannels(ctx, getFileSystemProvider, getLogProvider)...)
return ChainChannels(channels...), nil
}

View File

@@ -0,0 +1,62 @@
package senders
import (
"context"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/notification/channels"
"github.com/zitadel/zitadel/internal/notification/channels/fs"
"github.com/zitadel/zitadel/internal/notification/channels/instrumenting"
"github.com/zitadel/zitadel/internal/notification/channels/log"
"github.com/zitadel/zitadel/internal/notification/channels/sms"
"github.com/zitadel/zitadel/internal/notification/channels/twilio"
"github.com/zitadel/zitadel/internal/notification/channels/webhook"
)
const twilioSpanName = "twilio.NotificationChannel"
func SMSChannels(
ctx context.Context,
smsConfig *sms.Config,
getFileSystemProvider func(ctx context.Context) (*fs.Config, error),
getLogProvider func(ctx context.Context) (*log.Config, error),
successMetricName,
failureMetricName string,
) (chain *Chain, err error) {
channels := make([]channels.NotificationChannel, 0, 3)
if smsConfig.TwilioConfig != nil {
channels = append(
channels,
instrumenting.Wrap(
ctx,
twilio.InitChannel(*smsConfig.TwilioConfig),
twilioSpanName,
successMetricName,
failureMetricName,
),
)
}
if smsConfig.WebhookConfig != nil {
webhookChannel, err := webhook.InitChannel(ctx, *smsConfig.WebhookConfig)
logging.WithFields(
"instance", authz.GetInstance(ctx).InstanceID(),
"callurl", smsConfig.WebhookConfig.CallURL,
).OnError(err).Debug("initializing JSON channel failed")
if err == nil {
channels = append(
channels,
instrumenting.Wrap(
ctx,
webhookChannel,
webhookSpanName,
successMetricName,
failureMetricName,
),
)
}
}
channels = append(channels, debugChannels(ctx, getFileSystemProvider, getLogProvider)...)
return ChainChannels(channels...), nil
}

View File

@@ -0,0 +1,49 @@
package senders
import (
"context"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/notification/channels"
"github.com/zitadel/zitadel/internal/notification/channels/fs"
"github.com/zitadel/zitadel/internal/notification/channels/instrumenting"
"github.com/zitadel/zitadel/internal/notification/channels/log"
"github.com/zitadel/zitadel/internal/notification/channels/webhook"
)
const webhookSpanName = "webhook.NotificationChannel"
func WebhookChannels(
ctx context.Context,
webhookConfig webhook.Config,
getFileSystemProvider func(ctx context.Context) (*fs.Config, error),
getLogProvider func(ctx context.Context) (*log.Config, error),
successMetricName,
failureMetricName string,
) (*Chain, error) {
if err := webhookConfig.Validate(); err != nil {
return nil, err
}
channels := make([]channels.NotificationChannel, 0, 3)
webhookChannel, err := webhook.InitChannel(ctx, webhookConfig)
logging.WithFields(
"instance", authz.GetInstance(ctx).InstanceID(),
"callurl", webhookConfig.CallURL,
).OnError(err).Debug("initializing JSON channel failed")
if err == nil {
channels = append(
channels,
instrumenting.Wrap(
ctx,
webhookChannel,
webhookSpanName,
successMetricName,
failureMetricName,
),
)
}
channels = append(channels, debugChannels(ctx, getFileSystemProvider, getLogProvider)...)
return ChainChannels(channels...), nil
}

View File

@@ -0,0 +1,78 @@
InitCode:
Title: ZITADEL - Инициализиране на потребител
PreHeader: Инициализиране на потребителя
Subject: Инициализиране на потребителя
Greeting: 'Здравейте {{.DisplayName}},'
Text: >-
Този потребител е създаден в ZITADEL. {{.PreferredLoginName}} за да
влезете. {{.Code}}) Ако не сте поискали този имейл, моля, игнорирайте го.
ButtonText: Завършете инициализацията
PasswordReset:
Title: ZITADEL - Нулиране на парола
PreHeader: Нулирайте паролата
Subject: Нулирайте паролата
Greeting: 'Здравейте {{.DisplayName}},'
Text: >-
Получихме заявка за повторно задаване на парола. {{.Code}}) Ако не сте
поискали този имейл, моля, игнорирайте го.
ButtonText: Нулирайте паролата
VerifyEmail:
Title: ZITADEL - Потвърдете имейл
PreHeader: Потвърди Имейл
Subject: Потвърди Имейл
Greeting: 'Здравейте {{.DisplayName}},'
Text: >-
Добавен е нов имейл. {{.Code}}) Ако не сте добавили нов имейл, моля,
игнорирайте този имейл.
ButtonText: Потвърди Имейл
VerifyPhone:
Title: ZITADEL - Потвърдете телефона
PreHeader: Потвърдете телефона
Subject: Потвърдете телефона
Greeting: 'Здравейте {{.DisplayName}},'
Text: 'Добавен е нов телефонен номер. {{.Code}}'
ButtonText: Потвърдете телефона
VerifyEmailOTP:
Title: ZITADEL - Потвърди временната парола
PreHeader: Потвърди временната парола
Subject: Потвърди временната парола
Greeting: Здравей, {{.DisplayName}},
Text: Моля, използвай бутона 'Удостовери' или копирай временната парола {{.OTP}} и я постави на екрана за удостоверяване, за да се удостовериш в ZITADEL в рамките на следващите пет минути.
ButtonText: Удостовери
VerifySMSOTP:
Text: >-
{{.OTP}} е вашата еднократна парола за {{ .Domain }}. Използвайте го в рамките на следващия {{.Expiry}}.
@{{.Domain}} #{{.OTP}}
DomainClaimed:
Title: ZITADEL - Домейнът е заявен
PreHeader: Промяна на имейл/потребителско име
Subject: Домейнът е заявен
Greeting: 'Здравейте {{.DisplayName}},'
Text: >-
Домейнът {{.Domain}} е заявено от организация. {{.Username}} не е част от
тази организация. {{.TempUsername}}) за това влизане.
ButtonText: Влизам
PasswordlessRegistration:
Title: ZITADEL - Добавете вход без парола
PreHeader: Добавете вход без парола
Subject: Добавете вход без парола
Greeting: 'Здравейте {{.DisplayName}},'
Text: 'Получихме заявка за добавяне на токен за влизане без парола. '
ButtonText: Добавете вход без парола
PasswordChange:
Title: ZITADEL - Паролата на потребителя е променена
PreHeader: Промяна на паролата
Subject: Паролата на потребителя е променена
Greeting: 'Здравейте {{.DisplayName}},'
Text: >-
Паролата на вашия потребител е променена, ако тази промяна не е направена от
вас, моля, незабавно нулирайте паролата си.
ButtonText: Влизам
InviteUser:
Title: Покана за {{.ApplicationName}}
PreHeader: Покана за {{.ApplicationName}}
Subject: Покана за {{.ApplicationName}}
Greeting: 'Здравейте {{.DisplayName}},'
Text: Вашият потребител е бил поканен за {{.ApplicationName}}. Моля, кликнете върху бутона по-долу, за да завършите процеса на покана. Ако не сте поискали този имейл, моля, игнорирайте го.
ButtonText: Приеми поканата

View File

@@ -0,0 +1,68 @@
InitCode:
Title: Inicializace uživatele
PreHeader: Inicializace uživatele
Subject: Inicializace uživatele
Greeting: Dobrý den, {{.DisplayName}},
Text: Tento uživatel byl vytvořen. Pro přihlášení použijte uživatelské jméno {{.PreferredLoginName}}. Prosím klikněte na tlačítko níže k dokončení procesu inicializace. (Kód {{.Code}}) Pokud jste o tento email nepožádali, prosím ignorujte ho.
ButtonText: Dokončit inicializaci
PasswordReset:
Title: Reset hesla
PreHeader: Reset hesla
Subject: Reset hesla
Greeting: Dobrý den, {{.DisplayName}},
Text: Přijali jsme žádost o reset hesla. Pro reset hesla prosím použijte tlačítko níže. (Kód {{.Code}}) Pokud jste o tento email nepožádali, prosím ignorujte ho.
ButtonText: Reset hesla
VerifyEmail:
Title: Ověření emailu
PreHeader: Ověření emailu
Subject: Ověření emailu
Greeting: Dobrý den, {{.DisplayName}},
Text: Byl přidán nový email. Pro ověření vašeho emailu prosím použijte tlačítko níže. (Kód {{.Code}}) Pokud jste nepřidávali nový email, prosím ignorujte tento email.
ButtonText: Ověřit email
VerifyPhone:
Title: Ověření telefonu
PreHeader: Ověření telefonu
Subject: Ověření telefonu
Greeting: Dobrý den, {{.DisplayName}},
Text: Bylo přidáno nové telefonní číslo. Prosím použijte následující kód k jeho ověření {{.Code}}
ButtonText: Ověřit telefon
VerifyEmailOTP:
Title: Ověření jednorázového hesla
PreHeader: Ověření jednorázového hesla
Subject: Ověření jednorázového hesla
Greeting: Dobrý den, {{.DisplayName}},
Text: Prosím použijte jednorázové heslo {{.OTP}} k ověření během následujících pěti minut nebo klikněte na tlačítko "Ověřit".
ButtonText: Ověřit
VerifySMSOTP:
Text: >-
{{.OTP}} je vaše jednorázové heslo pro {{ .Domain }}. Použijte jej během následujících {{.Expiry}}.
@{{.Domain}} #{{.OTP}}
DomainClaimed:
Title: Doména byla přiřazena
PreHeader: Změna emailu / uživatelského jména
Subject: Doména byla přiřazena
Greeting: Dobrý den, {{.DisplayName}},
Text: Doména {{.Domain}} byla přiřazena organizaci. Váš současný uživatel {{.Username}} není součástí této organizace. Proto budete muset změnit svůj email při přihlášení. Pro toto přihlášení jsme vytvořili dočasné uživatelské jméno ({{.TempUsername}}).
ButtonText: Přihlásit se
PasswordlessRegistration:
Title: Přidat přihlášení bez hesla
PreHeader: Přidat přihlášení bez hesla
Subject: Přidat přihlášení bez hesla
Greeting: Dobrý den, {{.DisplayName}},
Text: Přijali jsme žádost o přidání tokenu pro přihlášení bez hesla. Pro přidání vašeho tokenu nebo zařízení pro přihlášení bez hesla prosím použijte tlačítko níže.
ButtonText: Přidat přihlášení bez hesla
PasswordChange:
Title: Heslo uživatele bylo změněno
PreHeader: Změna hesla
Subject: Heslo uživatele bylo změněno
Greeting: Dobrý den, {{.DisplayName}},
Text: Heslo vašeho uživatele bylo změněno. Pokud tato změna nebyla provedena Vámi pak doporučujeme okamžitě resetovat/změnit vaše heslo.
ButtonText: Přihlásit se
InviteUser:
Title: Pozvánka do {{.ApplicationName}}
PreHeader: Pozvánka do {{.ApplicationName}}
Subject: Pozvánka do {{.ApplicationName}}
Greeting: Dobrý den, {{.DisplayName}},
Text: Váš uživatel byl pozván do {{.ApplicationName}}. Klikněte prosím na tlačítko níže, abyste dokončili proces pozvání. Pokud jste o tento e-mail nepožádali, prosím, ignorujte ho.
ButtonText: Přijmout pozvání

View File

@@ -0,0 +1,68 @@
InitCode:
Title: ZITADEL - User initialisieren
PreHeader: User initialisieren
Subject: User initialisieren
Greeting: Hallo {{.DisplayName}},
Text: Dieser Benutzer wurde soeben erstellt. Mit dem Benutzernamen &lt;br&gt;&lt;strong&gt;{{.PreferredLoginName}}&lt;/strong&gt;&lt;br&gt; kannst du dich anmelden. Nutze den unten stehenden Button, um die Initialisierung abzuschliessen &lt;br&gt;(Code &lt;strong&gt;{{.Code}}&lt;/strong&gt;).&lt;br&gt; Falls du dieses E-Mail nicht angefordert hast, kannst du sie einfach ignorieren.
ButtonText: Initialisierung abschliessen
PasswordReset:
Title: Passwort zurücksetzen
PreHeader: Passwort zurücksetzen
Subject: Passwort zurücksetzen
Greeting: Hallo {{.DisplayName}},
Text: Wir haben eine Anfrage zum Zurücksetzen deines Passworts bekommen. Du kannst den unten stehenden Button verwenden, um dein Passwort zurückzusetzen &lt;br&gt;(Code &lt;strong&gt;{{.Code}}&lt;/strong&gt;).&lt;br&gt; Falls du das Zurücksetzen des Passworts nicht angefordert hast, kannst du diese E-Mail ignorieren.
ButtonText: Passwort zurücksetzen
VerifyEmail:
Title: E-Mail-Adresse verifizieren
PreHeader: E-Mail-Adresse verifizieren
Subject: E-Mail-Adresse verifizieren
Greeting: Hallo {{.DisplayName}},
Text: Eine neue E-Mail-Adresse wurde hinzugefügt. Bitte verwende den unten stehenden Button, um diese zu verifizieren &lt;br&gt;(Code &lt;strong&gt;{{.Code}}&lt;/strong&gt;).&lt;br&gt; Falls du die E-Mail-Adresse nicht selbst hinzugefügt hast, kannst du diese E-Mail ignorieren.
ButtonText: E-Mail-Adresse verifizieren
VerifyPhone:
Title: Telefonnummer verifizieren
PreHeader: Telefonnummer verifizieren
Subject: Telefonnummer verifizieren
Greeting: Hallo {{.DisplayName}},
Text: Eine Telefonnummer wurde hinzugefügt. Bitte verifiziere diese, indem du folgenden Code eingibst&lt;br&gt;(Code &lt;strong&gt;{{.Code}}&lt;/strong&gt;).&lt;br&gt;
ButtonText: Telefonnummer verifizieren
VerifyEmailOTP:
Title: Einmalpasswort verifizieren
PreHeader: Einmalpasswort verifizieren
Subject: Einmalpasswort verifizieren
Greeting: Hallo {{.DisplayName}},
Text: Bitte nutze den 'Authentifizieren'-Button oder kopiere das Einmalpasswort {{.OTP}} und gib es zur Bestätigung ein, um dich innerhalb der nächsten fünf Minuten zu authentifizieren.
ButtonText: Authentifizieren
VerifySMSOTP:
Text: >-
{{.OTP}} ist dein Einmalpasswort für {{ .Domain }}. Verwende es innerhalb der nächsten {{.Expiry}}.
@{{.Domain}} #{{.OTP}}
DomainClaimed:
Title: Domain wurde beansprucht
PreHeader: E-Mail / Benutzername ändern
Subject: Domain wurde beansprucht
Greeting: Hallo {{.DisplayName}},
Text: Die Domain {{.Domain}} wurde von einer Organisation beansprucht. Dein derzeitiger Benutzer {{.Username}} ist nicht Teil dieser Organisation. Daher musst du beim nächsten Login eine neue E-Mail-Adresse hinterlegen. Für diesen Login haben wir dir einen temporären Usernamen ({{.TempUsername}}) erstellt.
ButtonText: Login
PasswordlessRegistration:
Title: Passwortlosen Login hinzufügen
PreHeader: Passwortlosen Login hinzufügen
Subject: Passwortlosen Login hinzufügen
Greeting: Hallo {{.DisplayName}},
Text: Wir haben eine Anfrage für das Hinzufügen eines Token für den passwortlosen Login erhalten. Du kannst den unten stehenden Button verwenden, um dein Token oder Gerät hinzuzufügen.
ButtonText: Passwortlosen Login hinzufügen
PasswordChange:
Title: Passwort wurde geändert
PreHeader: Passwortänderung
Subject: Passwort wurde geändert
Greeting: Hallo {{.DisplayName}},
Text: Dein Passwort wurde geändert. Wenn diese Änderung nicht von dir gemacht wurde, empfehlen wir das sofortige Zurücksetzen deines Passworts.
ButtonText: Login
InviteUser:
Title: Einladung zu {{.ApplicationName}}
PreHeader: Einladung zu {{.ApplicationName}}
Subject: Einladung zu {{.ApplicationName}}
Greeting: Hallo {{.DisplayName}},
Text: Ihr Benutzer wurde zu {{.ApplicationName}} eingeladen. Bitte klicken Sie auf die Schaltfläche unten, um den Einladungsprozess abzuschließen. Wenn Sie diese E-Mail nicht angefordert haben, ignorieren Sie sie bitte.
ButtonText: Einladung annehmen

View File

@@ -0,0 +1,68 @@
InitCode:
Title: Initialize User
PreHeader: Initialize User
Subject: Initialize User
Greeting: Hello {{.DisplayName}},
Text: This user was created. Use the username {{.PreferredLoginName}} to login. Please click the button below to finish the initialization process. (Code {{.Code}}) If you didn't ask for this mail, please ignore it.
ButtonText: Finish initialization
PasswordReset:
Title: Reset password
PreHeader: Reset password
Subject: Reset password
Greeting: Hello {{.DisplayName}},
Text: We received a password reset request. Please use the button below to reset your password. (Code {{.Code}}) If you didn't ask for this mail, please ignore it.
ButtonText: Reset password
VerifyEmail:
Title: Verify email
PreHeader: Verify email
Subject: Verify email
Greeting: Hello {{.DisplayName}},
Text: A new email has been added. Please use the button below to verify your email. (Code {{.Code}}) If you didn't add a new email, please ignore this email.
ButtonText: Verify email
VerifyPhone:
Title: Verify phone
PreHeader: Verify phone
Subject: Verify phone
Greeting: Hello {{.DisplayName}},
Text: A new phone number has been added. Please use the following code to verify it {{.Code}}
ButtonText: Verify phone
VerifyEmailOTP:
Title: Verify One-Time Password
PreHeader: Verify One-Time Password
Subject: Verify One-Time Password
Greeting: Hello {{.DisplayName}},
Text: Please use the one-time password {{.OTP}} to authenticate within the next five minutes or click the "Authenticate" button.
ButtonText: Authenticate
VerifySMSOTP:
Text: >-
{{.OTP}} is your one-time password for {{ .Domain }}. Use it within the next {{.Expiry}}.
@{{.Domain}} #{{.OTP}}
DomainClaimed:
Title: Domain has been claimed
PreHeader: Change email / username
Subject: Domain has been claimed
Greeting: Hello {{.DisplayName}},
Text: The domain {{.Domain}} has been claimed by an organization. Your current user {{.Username}} is not part of this organization. Therefore you'll have to change your email when you login. We have created a temporary username ({{.TempUsername}}) for this login.
ButtonText: Login
PasswordlessRegistration:
Title: Add Passwordless Login
PreHeader: Add Passwordless Login
Subject: Add Passwordless Login
Greeting: Hello {{.DisplayName}},
Text: We received a request to add a token for passwordless login. Please use the button below to add your token or device for passwordless login.
ButtonText: Add Passwordless Login
PasswordChange:
Title: Password of user has changed
PreHeader: Change password
Subject: Password of user has changed
Greeting: Hello {{.DisplayName}},
Text: The password of your user has changed. If this change was not done by you, please be advised to immediately reset your password.
ButtonText: Login
InviteUser:
Title: Invitation to {{.ApplicationName}}
PreHeader: Invitation to {{.ApplicationName}}
Subject: Invitation to {{.ApplicationName}}
Greeting: Hello {{.DisplayName}},
Text: Your user has been invited to {{.ApplicationName}}. Please click the button below to finish the invite process. If you didn't ask for this mail, please ignore it.
ButtonText: Accept invite

View File

@@ -0,0 +1,68 @@
InitCode:
Title: ZITADEL - Inicializar usuario
PreHeader: Inicializar usuario
Subject: Inicializar usuario
Greeting: Hola {{.DisplayName}},
Text: Este usuario fue creado en ZITADEL. Utiliza el nombre de usuario {{.PreferredLoginName}} para iniciar sesión. Por favor, haz clic en el botón más abajo para finalizar el proceso de inicialización. (Código {{.Code}}) Si no solicitaste este correo electrónico, por favor ignóralo.
ButtonText: Finalizar inicialización
PasswordReset:
Title: ZITADEL - Restablecer contraseña
PreHeader: Restablecer contraseña
Subject: Restablecer contraseña
Greeting: Hola {{.DisplayName}},
Text: Hemos recibido una solicitud de restablecimiento de contraseña. Por favor, usa el botón más abajo para restablecer tu contraseña. (Código {{.Code}}) Si no solicitaste este correo electrónico, por favor ignóralo.
ButtonText: Restablecer contraseña
VerifyEmail:
Title: ZITADEL - Verificar correo electrónico
PreHeader: Verificar correo electrónico
Subject: Verificar correo electrónico
Greeting: Hola {{.DisplayName}},
Text: Se ha añadido una nueva dirección de correo electrónico. Por favor, usa el botón más abajo para verificar tu correo electrónico. (Código {{.Code}}) Si no añadiste una nueva dirección de correo electrónico, por favor ignora este correo.
ButtonText: Verificar correo electrónico
VerifyPhone:
Title: ZITADEL - Verificar teléfono
PreHeader: Verificar teléfono
Subject: Verificar teléfono
Greeting: Hola {{.DisplayName}},
Text: Se ha añadido un nuevo número de teléfono. Por favor, usa el siguiente código para verificarlo {{.Code}}
ButtonText: Verificar teléfono
VerifyEmailOTP:
Title: ZITADEL - Verifica la contraseña de un solo uso
PreHeader: Verifica la contraseña de un solo uso
Subject: Verifica la contraseña de un solo uso
Greeting: Hola {{.DisplayName}},
Text: Por favor, utiliza el botón 'Autenticar' o copia la contraseña de un solo uso {{.OTP}} y pégala en la pantalla de autenticación para autenticarte en ZITADEL en los próximos cinco minutos.
ButtonText: Autenticar
VerifySMSOTP:
Text: >-
{{.OTP}} es su contraseña de un solo uso para {{ .Domain }}. Úselo dentro de los próximos {{.Expiry}}.
@{{.Domain}} #{{.OTP}}
DomainClaimed:
Title: ZITADEL - Se ha reclamado un dominio
PreHeader: Cambiar dirección de correo electrónico / nombre de usuario
Subject: Se ha reclamado un dominio
Greeting: Hola {{.DisplayName}},
Text: El dominio {{.Domain}} ha sido reclamado por una organización. Tu usuario actual {{.Username}} no es parte de esta organización. Por tanto tendrás que cambiar tu dirección de correo electrónico cuando inicies sesión. Hemos creado un nombre de usuario temporal ({{.TempUsername}}) para este inicio de sesión.
ButtonText: Iniciar sesión
PasswordlessRegistration:
Title: ZITADEL - Añadir inicio de sesión sin contraseña
PreHeader: Añadir inicio de sesión sin contraseña
Subject: Añadir inicio de sesión sin contraseña
Greeting: Hola {{.DisplayName}},
Text: Hemos recibido una solicitud para añadir un token para iniciar sesión sin contraseña. Por favor, usa el botón más abajo para añadir tu token o dispositivo para un inicio de sesión sin contraseña.
ButtonText: Añadir inicio de sesión sin contraseña
PasswordChange:
Title: ZITADEL - La contraseña de usuario ha sido cambiada
PreHeader: Cambiar contraseña
Subject: La contraseña de usuario ha sido cambiada
Greeting: Hola {{.DisplayName}},
Text: La contraseña de tu usuario ha sido cambiada, si este cambio no fue hecho por ti, por favor proceder a restablecer inmediatamente tu contraseña.
ButtonText: Iniciar sesión
InviteUser:
Title: Invitación a {{.ApplicationName}}
PreHeader: Invitación a {{.ApplicationName}}
Subject: Invitación a {{.ApplicationName}}
Greeting: Hola {{.DisplayName}},
Text: Tu usuario ha sido invitado a {{.ApplicationName}}. Haz clic en el botón de abajo para finalizar el proceso de invitación. Si no solicitaste este correo electrónico, por favor ignóralo.
ButtonText: Aceptar invitación

View File

@@ -0,0 +1,68 @@
InitCode:
Title: ZITADEL - Initialiser l'utilisateur
PreHeader: Initialiser l'utilisateur
Subject: Initialiser l'utilisateur
Greeting: Bonjour {{.DisplayName}},
Text: Cet utilisateur a été créé dans ZITADEL. Utilisez le nom d'utilisateur {{.PreferredLoginName}} pour vous connecter. Veuillez cliquer sur le bouton ci-dessous pour terminer le processus d'initialisation. (Code {{.Code}}) Si vous n'avez pas demandé ce courrier, veuillez l'ignorer.
ButtonText: Terminer l'initialisation
PasswordReset:
Title: ZITADEL - Réinitialiser le mot de passe
PreHeader: Réinitialiser le mot de passe
Subject: Réinitialiser le mot de passe
Greeting: Bonjour {{.DisplayName}},
Text: Nous avons reçu une demande de réinitialisation du mot de passe. Veuillez utiliser le bouton ci-dessous pour réinitialiser votre mot de passe. (Code {{.Code}}) Si vous n'avez pas demandé cet e-mail, veuillez l'ignorer.
ButtonText: Réinitialiser le mot de passe
VerifyEmail:
Title: ZITADEL - Vérifier l'email
PreHeader: Vérifier l'email
Subject: Vérifier l'email
Greeting: Bonjour {{.DisplayName}},
Text: Un nouveau courriel a été ajouté. Veuillez utiliser le bouton ci-dessous pour vérifier votre e-mail. (Code {{.Code}}) Si vous n'avez pas ajouté de nouvelle adresse e-mail, veuillez ignorer cet e-mail.
ButtonText: Vérifier l'email
VerifyPhone:
Title: ZITADEL - Vérifier le téléphone
PreHeader: Vérifier le téléphone
Subject: Vérifier le téléphone
Greeting: Bonjour {{.DisplayName}},
Text: Un nouveau numéro de téléphone a été ajouté. Veuillez utiliser le code suivant pour le vérifier {{.Code}}
ButtonText: Vérifier le téléphone
VerifyEmailOTP:
Title: ZITADEL - Vérifier le mot de passe à usage unique
PreHeader: Vérifier le mot de passe à usage unique
Subject: Vérifier le mot de passe à usage unique
Greeting: Bonjour {{.DisplayName}},
Text: Utilisez le bouton 'Authentifier' ou copiez le mot de passe à usage unique {{.OTP}} et collez-le à l'écran d'authentification pour vous authentifier sur ZITADEL dans les cinq prochaines minutes.
ButtonText: Authentifier
VerifySMSOTP:
Text: >-
{{.OTP}} est votre mot de passe à usage unique pour {{ .Domain }}. Utilisez-le dans les prochaines {{.Expiry}}.
@{{.Domain}} #{{.OTP}}
DomainClaimed:
Title: ZITADEL - Le domaine a été réclamé
PreHeader: Modifier l'email / le nom d'utilisateur
Subject: Le domaine a été réclamé
Greeting: Bonjour {{.DisplayName}},
Text: Le domaine {{.Domain}} a été revendiqué par une organisation. Votre utilisateur actuel {{.Username}} ne fait pas partie de cette organisation. Par conséquent, vous devrez changer votre adresse électronique lors de votre connexion. Nous avons créé un nom d'utilisateur temporaire ({{.TempUsername}}) pour cette connexion.
ButtonText: Connexion
PasswordlessRegistration:
Title: ZITADEL - Ajouter une connexion sans mot de passe
PreHeader: Ajouter une connexion sans mot de passe
Subject: Ajouter une connexion sans mot de passe
Greeting: Bonjour {{.DisplayName}},
Text: Nous avons reçu une demande d'ajout d'un jeton pour la connexion sans mot de passe. Veuillez utiliser le bouton ci-dessous pour ajouter votre jeton ou dispositif pour la connexion sans mot de passe.
ButtonText: Ajouter une connexion sans mot de passe
PasswordChange:
Title: ZITADEL - Le mot de passe de l'utilisateur a changé
PreHeader: Modifier le mot de passe
Subject: Le mot de passe de l'utilisateur a changé
Greeting: Bonjour {{.DisplayName}},
Text: Le mot de passe de votre utilisateur a changé, si ce changement n'a pas été fait par vous, nous vous conseillons de réinitialiser immédiatement votre mot de passe.
ButtonText: Login
InviteUser:
Title: Invitation à {{.ApplicationName}}
PreHeader: Invitation à {{.ApplicationName}}
Subject: Invitation à {{.ApplicationName}}
Greeting: Bonjour {{.DisplayName}},
Text: Votre utilisateur a été invité à {{.ApplicationName}}. Veuillez cliquer sur le bouton ci-dessous pour terminer le processus d'invitation. Si vous n'avez pas demandé cet e-mail, veuillez l'ignorer.
ButtonText: Accepter l'invitation

View File

@@ -0,0 +1,78 @@
InitCode:
Title: Felhasználó Inicializálása
PreHeader: Felhasználó Inicializálása
Subject: Felhasználó Inicializálása
Greeting: "Kedves {{.DisplayName}},"
Text: "Ez a felhasználó létre lett hozva. Használd a {{.PreferredLoginName}} felhasználónevet a bejelentkezéshez. Kérlek, kattints az alábbi gombra az inicializálási folyamat befejezéséhez. (Kód: {{.Code}}) Ha nem te kérted ezt az e-mailt, kérlek, hagyd figyelmen kívül."
ButtonText: Inicializálás befejezése
PasswordReset:
Title: Jelszó visszaállítása
PreHeader: Jelszó visszaállítása
Subject: Jelszó visszaállítása
Greeting: "Kedves {{.DisplayName}},"
Text: "Jelszó-visszaállítási kérelmet kaptunk. Kérjük, használd az alábbi gombot a jelszavad visszaállításához. (Kód: {{.Code}}) Ha nem kérted ezt az e-mailt, kérjük, hagyd figyelmen kívül."
ButtonText: Jelszó visszaállítása
VerifyEmail:
Title: E-mail megerősítése
PreHeader: E-mail megerősítése
Subject: E-mail megerősítése
Greeting: "Kedves {{.DisplayName}},"
Text: "Egy új e-mail hozzá lett adva. Kérjük, használd az alábbi gombot az e-mail címed megerősítéséhez. (Kód: {{.Code}}) Ha nem adtál hozzá új e-mail címet, kérjük, hagyd figyelmen kívül ezt az e-mailt."
ButtonText: E-mail megerősítése
VerifyPhone:
Title: Telefon megerősítése
PreHeader: Telefon megerősítése
Subject: Telefonszám ellenőrzése
Greeting: "Kedves {{.DisplayName}},"
Text: "Új telefonszám került hozzáadásra. Kérlek, használd a következő kódot az ellenőrzéshez: {{.Code}}"
ButtonText: Telefonszám ellenőrzése
VerifyEmailOTP:
Title: Egyszer használatos jelszó ellenőrzése
PreHeader: Egyszer használatos jelszó ellenőrzése
Subject: Egyszer használatos jelszó ellenőrzése
Greeting: "Kedves {{.DisplayName}},"
Text: "Kérlek, használd az egyszer használatos jelszót {{.OTP}} azonosításhoz az elkövetkező öt percben, vagy kattints az 'Azonosítás' gombra."
ButtonText: Azonosítás
VerifySMSOTP:
Text: >-
{{.OTP}} Egyszerhasználatos jelszavad a(z) {{ .Domain }} oldalhoz. Eddig lesz érvényes: {{.Expiry}}.
{{.Domain}} #{{.OTP}}
DomainClaimed:
Title: A domaint már igényelték
PreHeader: E-mail / felhasználónév megváltoztatása
Subject: A domaint már igényelték
Greeting: "Kedves {{.DisplayName}},"
Text: "A {{.Domain}} domaint egy szervezet igényelte. A jelenlegi felhasználód, {{.Username}}, nem része ennek a szervezetnek. Ezért meg kell változtatnod az e-mailed, amikor bejelentkezel. Létrehoztunk egy ideiglenes felhasználónevet ({{.TempUsername}}) ehhez a bejelentkezéshez."
ButtonText: Bejelentkezés
PasswordlessRegistration:
Title: Jelszó nélküli bejelentkezés hozzáadása
PreHeader: Jelszó nélküli bejelentkezés hozzáadása
Subject: Jelszó nélküli bejelentkezés hozzáadása
Greeting: "Kedves {{.DisplayName}},"
Text: "Kaptunk egy kérést a jelszó nélküli bejelentkezéshez szükséges token hozzáadására. Kérlek, használd az alábbi gombot a token vagy eszköz hozzáadásához a jelszó nélküli bejelentkezéshez."
ButtonText: Jelszó Nélküli Bejelentkezés Hozzáadása
PasswordChange:
Title: A felhasználó jelszava megváltozott
PreHeader: Jelszó megváltoztatása
Subject: A felhasználó jelszava megváltozott
Greeting: "Kedves {{.DisplayName}},"
Text: "A felhasználó jelszava megváltozott. Ha ezt a változtatást nem te végezted, javasoljuk, hogy azonnal állítsd vissza a jelszavad."
ButtonText: Bejelentkezés
InviteUser:
Title: "Meghívás a(z) {{.ApplicationName}}-ra"
PreHeader: "Meghívás a(z) {{.ApplicationName}} szolgáltatásba"
Subject: "Meghívás a(z) {{.ApplicationName}} szolgáltatásba"
Greeting: "Kedves {{.DisplayName}},"
Text: "Felhasználódat meghívták a(z) {{.ApplicationName}} szolgáltatásba. Kérlek, kattints az alábbi gombra a meghívás folyamatának befejezéséhez. Ha nem kérted ezt az e-mailt, kérlek hagyd figyelmen kívül."
ButtonText: Meghívás elfogadása

View File

@@ -0,0 +1,68 @@
InitCode:
Title: Inisialisasi Pengguna
PreHeader: Inisialisasi Pengguna
Subject: Inisialisasi Pengguna
Greeting: 'Halo {{.DisplayName}},'
Text: Pengguna ini telah dibuat. {{.PreferredLoginName}} untuk masuk. {{.Code}}) Jika Anda tidak meminta email ini, abaikan saja.
ButtonText: Selesaikan inisialisasi
PasswordReset:
Title: Setel ulang kata sandi
PreHeader: Setel ulang kata sandi
Subject: Setel ulang kata sandi
Greeting: 'Halo {{.DisplayName}},'
Text: Kami menerima permintaan pengaturan ulang kata sandi. {{.Code}}) Jika Anda tidak meminta email ini, abaikan saja.
ButtonText: Setel ulang kata sandi
VerifyEmail:
Title: Verifikasi email
PreHeader: Verifikasi email
Subject: Verifikasi email
Greeting: 'Halo {{.DisplayName}},'
Text: Email baru telah ditambahkan. {{.Code}}) Jika Anda tidak menambahkan email baru, harap abaikan email ini.
ButtonText: Verifikasi email
VerifyPhone:
Title: Verifikasi telepon
PreHeader: Verifikasi telepon
Subject: Verifikasi telepon
Greeting: 'Halo {{.DisplayName}},'
Text: 'Nomor telepon baru telah ditambahkan. {{.Code}}'
ButtonText: Verifikasi telepon
VerifyEmailOTP:
Title: Verifikasi Kata Sandi Sekali Pakai
PreHeader: Verifikasi Kata Sandi Sekali Pakai
Subject: Verifikasi Kata Sandi Sekali Pakai
Greeting: 'Halo {{.DisplayName}},'
Text: Silakan gunakan kata sandi satu kali {{.OTP}} untuk mengautentikasi dalam lima menit berikutnya atau klik tombol "Otentikasi".
ButtonText: Otentikasi
VerifySMSOTP:
Text: >-
{{.OTP}} adalah kata sandi satu kali Anda untuk {{ .Domain }}. {{.Expiry}}.
{{.Domain}} #{{.OTP}}
DomainClaimed:
Title: Domain telah diklaim
PreHeader: Ganti email/nama pengguna
Subject: Domain telah diklaim
Greeting: 'Halo {{.DisplayName}},'
Text: Domainnya {{.Domain}} telah diklaim oleh suatu organisasi. {{.Username}} bukan bagian dari organisasi ini. {{.TempUsername}}) untuk login ini.
ButtonText: Login
PasswordlessRegistration:
Title: Tambahkan Login Tanpa Kata Sandi
PreHeader: Tambahkan Login Tanpa Kata Sandi
Subject: Tambahkan Login Tanpa Kata Sandi
Greeting: 'Halo {{.DisplayName}},'
Text: Kami menerima permintaan untuk menambahkan token untuk login tanpa kata sandi.
ButtonText: Tambahkan Login Tanpa Kata Sandi
PasswordChange:
Title: Kata sandi pengguna telah berubah
PreHeader: Ubah kata sandi
Subject: Kata sandi pengguna telah berubah
Greeting: 'Halo {{.DisplayName}},'
Text: 'Kata sandi pengguna Anda telah berubah. '
ButtonText: Login
InviteUser:
Title: Undangan ke {{.ApplicationName}}
PreHeader: Undangan ke {{.ApplicationName}}
Subject: Undangan ke {{.ApplicationName}}
Greeting: 'Halo {{.DisplayName}},'
Text: Pengguna Anda telah diundang ke {{.ApplicationName}}. Silakan klik tombol di bawah ini untuk menyelesaikan proses undangan. Jika Anda tidak meminta email ini, harap abaikan.
ButtonText: Terima undangan

View File

@@ -0,0 +1,68 @@
InitCode:
Title: ZITADEL - Inizializzare l'utente
PreHeader: Inizializzare l'utente
Subject: Inizializzare l'utente
Greeting: 'Ciao {{.DisplayName}},'
Text: Questo utente è stato creato in ZITADEL. Usa il nome utente {{.PreferredLoginName}} per accedere. Per favore, clicca il pulsante per finire il processo di inizializzazione. (Codice {{.Codice}}) Se non hai richiesto questa mail, per favore ignorala.
ButtonText: Termina
PasswordReset:
Title: ZITADEL - Ripristina la password
PreHeader: Ripristina la password
Subject: Ripristina la password
Greeting: 'Ciao {{.DisplayName}},'
Text: Abbiamo ricevuto una richiesta di reimpostazione della password. Per favore clicca il pulsante per resettare la tua password. (Codice {{.Codice}}) Se non hai richiesto questa mail, ignorala.
ButtonText: Ripristina
VerifyEmail:
Title: ZITADEL - Verifica l'e-mail
PreHeader: Verifica l'e-mail
Subject: Verifica l'e-mail
Greeting: 'Ciao {{.DisplayName}},'
Text: È stata aggiunta una nuova email. Per favore fai clic sul pulsante per verificare la tua mail. (Codice {{.Codice}}) Se non hai aggiunto una nuova email, ignora questa email.
ButtonText: Verifica
VerifyPhone:
Title: ZITADEL - Verifica il telefono
PreHeader: Verifica il telefono
Subject: Verifica il telefono
Greeting: 'Ciao {{.DisplayName}},'
Text: È stato aggiunto un nuovo numero di telefono. Usa il seguente codice per verificarlo {{.Code}}
ButtonText: Verifica
VerifyEmailOTP:
Title: ZITADEL - Verifica la password monouso
PreHeader: Verifica la password monouso
Subject: Verifica la password monouso
Greeting: Ciao {{.DisplayName}},
Text: Per favore, utilizza il pulsante 'Autentica' o copia la password monouso {{.OTP}} e incollala nella schermata di autenticazione per autenticarti a ZITADEL entro i prossimi cinque minuti.
ButtonText: Autentica
VerifySMSOTP:
Text: >-
{{.OTP}} è la tua password monouso per {{ .Domain }}. Usalo entro il prossimo {{.Expiry}}.
@{{.Domain}} #{{.OTP}}
DomainClaimed:
Title: ZITADEL - Il dominio è stato rivendicato
PreHeader: Cambiare email / nome utente
Subject: Il dominio è stato rivendicato
Greeting: 'Ciao {{.DisplayName}},'
Text: Il dominio {{.Domain}} è stato rivendicato da un'organizzazione. Il tuo attuale utente {{.Username}} non fa parte di questa organizzazione. Perciò dovrai cambiare la tua email quando farai il login. Abbiamo creato un nome utente temporaneo ({{.TempUsername}}) per questo login.
ButtonText: Accedi
PasswordlessRegistration:
Title: ZITADEL - Aggiungere autenticazione passwordless
PreHeader: Aggiungi l'autenticazione passwordless
Subject: Aggiungi l'autenticazione passwordless
Greeting: 'Ciao {{.DisplayName}},'
Text: Abbiamo ricevuto una richiesta per aggiungere l'autenticazione passwordless. Usa il pulsante qui sotto per aggiungere il tuo token o dispositivo per il login senza password.
ButtonText: Attiva passwordless
PasswordChange:
Title: ZITADEL - La password dell'utente è stata modificata
PreHeader: Modifica della password
Subject: La password dell'utente è stata modificata
Greeting: Ciao {{.DisplayName}},
Text: La password del vostro utente è cambiata; se questa modifica non è stata fatta da voi, vi consigliamo di reimpostare immediatamente la vostra password.
ButtonText: Login
InviteUser:
Title: Invito a {{.ApplicationName}}
PreHeader: Invito a {{.ApplicationName}}
Subject: Invito a {{.ApplicationName}}
Greeting: 'Ciao {{.DisplayName}},'
Text: Il tuo utente è stato invitato a {{.ApplicationName}}. Clicca sul pulsante qui sotto per completare il processo di invito. Se non hai richiesto questa email, ignorala.
ButtonText: Accetta invito

View File

@@ -0,0 +1,68 @@
InitCode:
Title: ZITADEL - ユーザーの初期セットアップ
PreHeader: ユーザーの初期セットアップ
Subject: ユーザーの初期セットアップ
Greeting: こんにちは {{.DisplayName}} さん、
Text: このユーザーはZITADELで作成されました。ユーザー名 {{.PreferredLoginName}} を使用してログインします。以下のボタンから、初期セットアップを完了してください。(コード {{.Code}})このメールの受信を希望していない場合は、無視してください。
ButtonText: 初期セットアップを完了する
PasswordReset:
Title: ZITADEL - パスワードをリセットします
PreHeader: パスワードの再設定
Subject: パスワードの再設定
Greeting: こんにちは {{.DisplayName}} さん、
Text: パスワードリセットのリクエストを受け取りました。以下のボタンから、パスワードをリセットしてください。(コード {{.Code}})このメールの受信を希望していない場合は、無視してください。
ButtonText: パスワードを再設定
VerifyEmail:
Title: ZITADEL - メールアドレスの認証
PreHeader: メールアドレスの認証
Subject: メールアドレスの認証
Greeting: こんにちは {{.DisplayName}} さん、
Text: 新しいメールアドレスが追加されました。以下のボタンから、メールアドレスを認証してください。(コード {{.Code}})新しいメールアドレスを追加していない場合は、このメールを無視してください。
ButtonText: メールアドレスを認証
VerifyPhone:
Title: ZITADEL - 電話番号の認証
PreHeader: 電話番号の認証
Subject: 電話番号の認証
Greeting: こんにちは {{.DisplayName}} さん、
Text: 新しい電話番号が追加されました。次のコードを使用してを認証してください {{.Code}}
ButtonText: 電話番号を認証
VerifyEmailOTP:
Title: ZITADEL - ワンタイムパスワードを確認する
PreHeader: ワンタイムパスワードを確認する
Subject: ワンタイムパスワードを確認する
Greeting: こんにちは、{{.DisplayName}}さん
Text: 認証ボタンを使用するか、ワンタイムパスワード {{.OTP}} をコピーして認証画面に貼り付け、次の5分以内にZITADELで認証してください。
ButtonText: 認証
VerifySMSOTP:
Text: >-
{{.OTP}} は、{{ .Domain }} のワンタイムパスワードです。次の {{.Expiry}} 以内に使用してください。
@{{.Domain}} #{{.OTP}}
DomainClaimed:
Title: ZITADEL - ドメインの登録
PreHeader: メールアドレス・ユーザー名の変更
Subject: ドメインの登録
Greeting: こんにちは {{.DisplayName}} さん、
Text: ドメイン {{.Domain}} が組織によって登録されました。現在のユーザー {{.Username}} はこの組織のメンバーでないため、ログイン時にメールアドレスを変更する必要があります。一時的なユーザー名({{.TempUsername}})を使用してログインし、設定を進めてください。
ButtonText: ログイン
PasswordlessRegistration:
Title: ZITADEL - パスワードレスログインの追加
PreHeader: パスワードレスログインの追加
Subject: パスワードレスログインの追加
Greeting: こんにちは {{.DisplayName}} さん、
Text: パスワードレスログイン用のトークンを追加するリクエストを受け取りました。以下のボタンから、パスワードレスログイン用のトークンやデバイスを追加してください。
ButtonText: パスワードレスログインを追加
PasswordChange:
Title: ZITADEL - ユーザーのパスワードが変更されました
PreHeader: パスワードの変更
Subject: ユーザーのパスワードが変更されました
Greeting: こんにちは {{.DisplayName}} さん、
Text: ユーザーのパスワードが変更されました。この変更があなたによって行われなかった場合は、すぐにパスワードをリセットすることをお勧めします。
ButtonText: ログイン
InviteUser:
Title: '{{.ApplicationName}}への招待'
PreHeader: '{{.ApplicationName}}への招待'
Subject: '{{.ApplicationName}}への招待'
Greeting: こんにちは {{.DisplayName}} さん、
Text: あなたのユーザーは{{.ApplicationName}}に招待されました。下のボタンをクリックして、招待プロセスを完了してください。このメールをリクエストしていない場合は、無視してください。
ButtonText: 招待を受け入れる

View File

@@ -0,0 +1,68 @@
InitCode:
Title: 사용자 초기화
PreHeader: 사용자 초기화
Subject: 사용자 초기화
Greeting: 안녕하세요, {{.DisplayName}}님,
Text: 사용자가 생성되었습니다. 로그인하려면 사용자 이름 {{.PreferredLoginName}}을 사용하세요. 초기화 프로세스를 완료하려면 아래 버튼을 클릭하세요. (코드 {{.Code}}) 이 메일을 요청하지 않으셨다면 무시하셔도 됩니다.
ButtonText: 초기화 완료
PasswordReset:
Title: 비밀번호 재설정
PreHeader: 비밀번호 재설정
Subject: 비밀번호 재설정
Greeting: 안녕하세요, {{.DisplayName}}님,
Text: 비밀번호 재설정 요청을 받았습니다. 비밀번호를 재설정하려면 아래 버튼을 사용하세요. (코드 {{.Code}}) 이 메일을 요청하지 않으셨다면 무시하셔도 됩니다.
ButtonText: 비밀번호 재설정
VerifyEmail:
Title: 이메일 인증
PreHeader: 이메일 인증
Subject: 이메일 인증
Greeting: 안녕하세요, {{.DisplayName}}님,
Text: 새 이메일이 추가되었습니다. 이메일을 인증하려면 아래 버튼을 사용하세요. (코드 {{.Code}}) 새로운 이메일을 추가하지 않으셨다면 이 메일을 무시하세요.
ButtonText: 이메일 인증
VerifyPhone:
Title: 전화번호 인증
PreHeader: 전화번호 인증
Subject: 전화번호 인증
Greeting: 안녕하세요, {{.DisplayName}}님,
Text: 새 전화번호가 추가되었습니다. 다음 코드를 사용하여 인증하세요 {{.Code}}
ButtonText: 전화번호 인증
VerifyEmailOTP:
Title: 일회용 비밀번호 인증
PreHeader: 일회용 비밀번호 인증
Subject: 일회용 비밀번호 인증
Greeting: 안녕하세요, {{.DisplayName}}님,
Text: 인증하려면 다음 일회용 비밀번호 {{.OTP}}를 5분 이내에 사용하거나 "인증" 버튼을 클릭하세요.
ButtonText: 인증
VerifySMSOTP:
Text: >-
{{.OTP}}는 {{ .Domain }}의 일회용 비밀번호입니다. 다음 {{.Expiry}} 이내에 사용하세요.
@{{.Domain}} #{{.OTP}}
DomainClaimed:
Title: 조직에서 도메인을 소유하게 되었습니다
PreHeader: 이메일 / 사용자 이름 변경
Subject: 조직에서 도메인을 소유하게 되었습니다
Greeting: 안녕하세요, {{.DisplayName}}님,
Text: 도메인 {{.Domain}} 은(는) 조직에서 소유하게 되었습니다. 현재 사용자인 {{.Username}}은 이 조직의 구성원이 아닙니다. 따라서 로그인할 때 이메일을 변경해야 합니다. 이 로그인용으로 임시 사용자 이름 ({{.TempUsername}})을 생성했습니다.
ButtonText: 로그인
PasswordlessRegistration:
Title: 비밀번호 없는 로그인 추가
PreHeader: 비밀번호 없는 로그인 추가
Subject: 비밀번호 없는 로그인 추가
Greeting: 안녕하세요, {{.DisplayName}}님,
Text: 비밀번호 없는 로그인을 위한 토큰 추가 요청을 받았습니다. 비밀번호 없는 로그인을 위해 토큰 또는 장치를 추가하려면 아래 버튼을 클릭하세요.
ButtonText: 비밀번호 없는 로그인 추가
PasswordChange:
Title: 사용자 비밀번호 변경됨
PreHeader: 비밀번호 변경
Subject: 사용자 비밀번호 변경됨
Greeting: 안녕하세요, {{.DisplayName}}님,
Text: 사용자의 비밀번호가 변경되었습니다. 본인이 직접 변경하지 않으셨다면 즉시 비밀번호를 재설정하시기 바랍니다.
ButtonText: 로그인
InviteUser:
Title: "{{.ApplicationName}} 초대"
PreHeader: "{{.ApplicationName}} 초대"
Subject: "{{.ApplicationName}} 초대"
Greeting: 안녕하세요, {{.DisplayName}}님,
Text: "{{.ApplicationName}}에 초대되었습니다. 초대 프로세스를 완료하려면 아래 버튼을 클릭하세요. 이 메일을 요청하지 않으셨다면 무시하셔도 됩니다."
ButtonText: 초대 수락

View File

@@ -0,0 +1,68 @@
InitCode:
Title: ZITADEL - Иницијализација на Корисник
PreHeader: Иницијализација на Корисник
Subject: Иницијализација на Корисник
Greeting: Здраво {{.DisplayName}},
Text: Овој корисник е креиран во ZITADEL. Користете го корисничкото име {{.PreferredLoginName}} за најава. Ве молиме кликнете на копчето подолу за да го завршите процесот на иницијализација. (Код {{.Code}}) Ако не сте барале/и оваа е-пошта, ве молиме игнорирајте ја.
ButtonText: Заврши иницијализација
PasswordReset:
Title: ZITADEL - Ресетирање на лозинка
PreHeader: Ресетирање на лозинка
Subject: Ресетирање на лозинка
Greeting: Здраво {{.DisplayName}},
Text: Добивме барање за ресетирање на лозинката. Ве молиме користете го копчето подолу за да ја ресетирате вашата лозинка. (Код {{.Code}}) Ако не сте ја побарале ресетирање на лозинка, ве молиме игнорирајте ја оваа е-пошта.
ButtonText: Ресетирај лозинка
VerifyEmail:
Title: ZITADEL - Верификација на е-пошта
PreHeader: Верификација на е-пошта
Subject: Верификација на е-пошта
Greeting: Здраво {{.DisplayName}},
Text: Додадена е нова е-пошта. Ве молиме користете го копчето подолу за да ја верифицирате вашата е-пошта. (Код {{.Code}}) Ако не сте додале нова е-пошта, ве молиме игнорирајте ја оваа е-пошта.
ButtonText: Верифицирај е-пошта
VerifyPhone:
Title: ZITADEL - Верификација на телефонски број
PreHeader: Верификација на телефонски број
Subject: Верификација на телефонски број
Greeting: Здраво {{.DisplayName}},
Text: Додаден е нов телефонски број. Ве молиме користете го следниот код за да го верифицирате. {{.Code}}
ButtonText: Верифицирај телефонски број
VerifyEmailOTP:
Title: ZITADEL - Потврди еднократна лозинка
PreHeader: Потврди еднократна лозинка
Subject: Потврди еднократна лозинка
Greeting: Здраво {{.DisplayName}},
Text: Ве молам, користи го копчето 'Автентицирај' или копирај ја еднократната лозинка {{.OTP}} и стави ја на екранот за автентикација за да се автентицираш на ZITADEL во следните пет минути.
ButtonText: Автентицирај
VerifySMSOTP:
Text: >-
{{.OTP}} е вашата еднократна лозинка за {{ .Domain }}. Користете го во следниот {{.Expiry}}.
@{{.Domain}} #{{.OTP}}
DomainClaimed:
Title: ZITADEL - Доменот е преземен
PreHeader: Промена на е-пошта / корисничко име
Subject: Доменот е преземен
Greeting: Здраво {{.DisplayName}},
Text: Доменот {{.Domain}} е преземен од страна на организација. Вашиот моментален корисник {{.Username}} не е дел од оваа организација. Затоа, при најава, треба да го промените вашиот е-поштенска адреса. Направивме привремено корисничко име ({{.TempUsername}}) за оваа најава.
ButtonText: Најави се
PasswordlessRegistration:
Title: ZITADEL - Додади Најава без Лозинка
PreHeader: Додади Најава без Лозинка
Subject: Додади Најава без Лозинка
Greeting: Здраво {{.DisplayName}},
Text: Добивме барање да додадеме токен за најава без лозинка. Ве молиме користете го копчето подолу за да додадете вашиот токен или уред за најава без лозинка.
ButtonText: Додади Најава без Лозинка
PasswordChange:
Title: ZITADEL - Лозинката на корисникот е променета
PreHeader: Промена на лозинка
Subject: Лозинката на корисникот е променета
Greeting: Здраво {{.DisplayName}},
Text: Лозинката на вашиот корисник е променета. Ако оваа промена не е извршена од вас, ве молиме веднаш ресетирајте ја вашата лозинка.
ButtonText: Најава
InviteUser:
Title: Покана за {{.ApplicationName}}
PreHeader: Покана за {{.ApplicationName}}
Subject: Покана за {{.ApplicationName}}
Greeting: Здраво {{.DisplayName}},
Text: Вашиот корисник е бил поканет за {{.ApplicationName}}. Ве молиме кликнете на копчето подолу за да го завршите процесот на покана. Ако не сте побарале овој мејл, ве молиме игнорирајте го.
ButtonText: Прифати покана

View File

@@ -0,0 +1,68 @@
InitCode:
Title: Gebruiker Initialiseren
PreHeader: Gebruiker Initialiseren
Subject: Gebruiker Initialiseren
Greeting: Hallo {{.DisplayName}},
Text: Deze gebruiker is aangemaakt. Gebruik de gebruikersnaam {{.PreferredLoginName}} om in te loggen. Klik op de knop hieronder om het initialisatieproces te voltooien. (Code {{.Code}}) Als u niet om deze mail heeft gevraagd, negeer deze dan.
ButtonText: Voltooi initialisatie
PasswordReset:
Title: Wachtwoord resetten
PreHeader: Wachtwoord resetten
Subject: Wachtwoord resetten
Greeting: Hallo {{.DisplayName}},
Text: We hebben een verzoek ontvangen om uw wachtwoord te resetten. Gebruik de knop hieronder om uw wachtwoord te resetten. (Code {{.Code}}) Als u niet om deze mail heeft gevraagd, negeer deze dan.
ButtonText: Reset wachtwoord
VerifyEmail:
Title: Verifieer email
PreHeader: Verifieer email
Subject: Verifieer email
Greeting: Hallo {{.DisplayName}},
Text: Er is een nieuwe email toegevoegd. Gebruik de knop hieronder om uw email te verifiëren. (Code {{.Code}}) Als u geen nieuwe email heeft toegevoegd, negeer deze email dan.
ButtonText: Verifieer email
VerifyPhone:
Title: Verifieer telefoon
PreHeader: Verifieer telefoon
Subject: Verifieer telefoon
Greeting: Hallo {{.DisplayName}},
Text: Er is een nieuw telefoonnummer toegevoegd. Gebruik de volgende code om het te verifiëren {{.Code}}
ButtonText: Verifieer telefoon
VerifyEmailOTP:
Title: Verifieer One-Time Wachtwoord
PreHeader: Verifieer One-Time Wachtwoord
Subject: Verifieer One-Time Wachtwoord
Greeting: Hallo {{.DisplayName}},
Text: Gebruik het eenmalige wachtwoord {{.OTP}} om binnen de volgende vijf minuten te authenticeren of klik op de "Authenticeer" knop.
ButtonText: Authenticeer
VerifySMSOTP:
Text: >-
{{.OTP}} is uw eenmalige wachtwoord voor {{ .Domain }}. Gebruik het binnen de volgende {{.Expiry}} minuten.
@{{.Domain}} #{{.OTP}}
DomainClaimed:
Title: Domein is geclaimd
PreHeader: Verander email / gebruikersnaam
Subject: Domein is geclaimd
Greeting: Hallo {{.DisplayName}},
Text: Het domein {{.Domain}} is geclaimd door een organisatie. Uw huidige gebruiker {{.Username}} is geen onderdeel van deze organisatie. Daarom moet u uw email veranderen wanneer u inlogt. We hebben een tijdelijke gebruikersnaam ({{.TempUsername}}) voor deze login gecreëerd.
ButtonText: Inloggen
PasswordlessRegistration:
Title: Voeg Wachtwoordloze Login Toe
PreHeader: Voeg Wachtwoordloze Login Toe
Subject: Voeg Wachtwoordloze Login Toe
Greeting: Hallo {{.DisplayName}},
Text: We hebben een verzoek ontvangen om een token toe te voegen voor wachtwoordloze login. Gebruik de knop hieronder om uw token of apparaat toe te voegen voor wachtwoordloze login.
ButtonText: Voeg Wachtwoordloze Login Toe
PasswordChange:
Title: Wachtwoord van gebruiker is veranderd
PreHeader: Verander wachtwoord
Subject: Wachtwoord van gebruiker is veranderd
Greeting: Hallo {{.DisplayName}},
Text: Het wachtwoord van uw gebruiker is veranderd. Als deze wijziging niet door u is gedaan, wordt u geadviseerd om direct uw wachtwoord te resetten.
ButtonText: Inloggen
InviteUser:
Title: Uitnodiging voor {{.ApplicationName}}
PreHeader: Uitnodiging voor {{.ApplicationName}}
Subject: Uitnodiging voor {{.ApplicationName}}
Greeting: Hallo {{.DisplayName}},
Text: Uw gebruiker is uitgenodigd voor {{.ApplicationName}}. Klik op de onderstaande knop om het uitnodigingsproces te voltooien. Als u deze e-mail niet hebt aangevraagd, negeer deze dan.
ButtonText: Uitnodiging accepteren

View File

@@ -0,0 +1,68 @@
InitCode:
Title: ZITADEL - Inicjalizacja użytkownika
PreHeader: Inicjalizacja użytkownika
Subject: Inicjalizacja użytkownika
Greeting: Witaj {{.DisplayName}},
Text: Ten użytkownik został utworzony w ZITADEL. Aby się zalogować, użyj nazwy użytkownika {{.PreferredLoginName}}. Kliknij poniższy przycisk, aby zakończyć proces inicjalizacji. (Kod {{.Code}}) Jeśli nie wystąpiła prośba o ten e-mail, proszę go zignorować.
ButtonText: Zakończ inicjalizację
PasswordReset:
Title: ZITADEL - Resetowanie hasła
PreHeader: Resetowanie hasła
Subject: Resetowanie hasła
Greeting: Witaj {{.DisplayName}},
Text: Otrzymaliśmy prośbę o resetowanie hasła. Kliknij poniższy przycisk, aby zresetować swoje hasło. (Kod {{.Code}}) Jeśli nie wystąpiła prośba o ten e-mail, proszę go zignorować.
ButtonText: Zresetuj hasło
VerifyEmail:
Title: ZITADEL - Weryfikacja adresu e-mail
PreHeader: Weryfikacja adresu e-mail
Subject: Weryfikacja adresu e-mail
Greeting: Witaj {{.DisplayName}},
Text: Został dodany nowy adres e-mail. Użyj poniższego przycisku, aby zweryfikować swój adres e-mail. (Kod {{.Code}}) Jeśli nie dodałeś nowego adresu e-mail, proszę zignoruj ten e-mail.
ButtonText: Zweryfikuj adres e-mail
VerifyPhone:
Title: ZITADEL - Weryfikacja numeru telefonu
PreHeader: Weryfikacja numeru telefonu
Subject: Weryfikacja numeru telefonu
Greeting: Witaj {{.DisplayName}},
Text: Został dodany nowy numer telefonu. Użyj następującego kodu, aby go zweryfikować {{.Code}}
ButtonText: Zweryfikuj numer telefonu
VerifyEmailOTP:
Title: ZITADEL - Zatwierdź hasło jednorazowe
PreHeader: Zatwierdź hasło jednorazowe
Subject: Zatwierdź hasło jednorazowe
Greeting: Witaj {{.DisplayName}},
Text: Proszę, użyj przycisku 'Uwierzytelnij' lub skopiuj hasło jednorazowe {{.OTP}} i wklej go na ekran uwierzytelniania, aby uwierzytelnić się w ZITADEL w ciągu najbliższych pięciu minut.
ButtonText: Uwierzytelnij
VerifySMSOTP:
Text: >-
{{.OTP}} to Twoje jednorazowe hasło do domeny {{ .Domain }}. Użyj go w ciągu najbliższych {{.Expiry}}.
@{{.Domain}} #{{.OTP}}
DomainClaimed:
Title: ZITADEL - Domena została zarejestrowana
PreHeader: Zmiana adresu e-mail / nazwy użytkownika
Subject: Domena została zarejestrowana
Greeting: Witaj {{.DisplayName}},
Text: Domena {{.Domain}} została zarejestrowana przez organizację. Twój obecny użytkownik {{.Username}} nie jest członkiem tej organizacji. Dlatego będziesz musiał zmienić swój adres e-mail podczas logowania. Stworzyliśmy tymczasową nazwę użytkownika ({{.TempUsername}}) dla tego logowania.
ButtonText: Zaloguj się
PasswordlessRegistration:
Title: ZITADEL - Dodaj logowanie bez hasła
PreHeader: Dodaj logowanie bez hasła
Subject: Dodaj logowanie bez hasła
Greeting: Witaj {{.DisplayName}},
Text: Otrzymaliśmy prośbę o dodanie tokenu do logowania bez hasła. Użyj poniższego przycisku, aby dodać swój token lub urządzenie do logowania bez hasła.
ButtonText: Dodaj logowanie bez hasła
PasswordChange:
Title: ZITADEL - Hasło użytkownika zostało zmienione
PreHeader: Zmiana hasła
Subject: Hasło użytkownika zostało zmienione
Greeting: Witaj {{.DisplayName}},
Text: Hasło Twojego użytkownika zostało zmienione, jeśli ta zmiana nie została dokonana przez Ciebie, zalecamy natychmiastowe zresetowanie hasła.
ButtonText: Zaloguj się
InviteUser:
Title: Zaproszenie do {{.ApplicationName}}
PreHeader: Zaproszenie do {{.ApplicationName}}
Subject: Zaproszenie do {{.ApplicationName}}
Greeting: Witaj {{.DisplayName}},
Text: Twój użytkownik został zaproszony do {{.ApplicationName}}. Kliknij poniższy przycisk, aby zakończyć proces zaproszenia. Jeśli nie zażądałeś tego e-maila, zignoruj go.
ButtonText: Akceptuj zaproszenie

View File

@@ -0,0 +1,68 @@
InitCode:
Title: ZITADEL - Inicializar Usuário
PreHeader: Inicializar Usuário
Subject: Inicializar Usuário
Greeting: Olá {{.DisplayName}},
Text: Este usuário foi criado no ZITADEL. Use o nome de usuário {{.PreferredLoginName}} para fazer login. Por favor, clique no botão abaixo para finalizar o processo de inicialização. (Código {{.Code}}) Se você não solicitou este e-mail, por favor, ignore-o.
ButtonText: Finalizar inicialização
PasswordReset:
Title: ZITADEL - Redefinir senha
PreHeader: Redefinir senha
Subject: Redefinir senha
Greeting: Olá {{.DisplayName}},
Text: Recebemos uma solicitação de redefinição de senha. Por favor, use o botão abaixo para redefinir sua senha. (Código {{.Code}}) Se você não solicitou este e-mail, por favor, ignore-o.
ButtonText: Redefinir senha
VerifyEmail:
Title: ZITADEL - Verificar e-mail
PreHeader: Verificar e-mail
Subject: Verificar e-mail
Greeting: Olá {{.DisplayName}},
Text: Um novo e-mail foi adicionado. Por favor, use o botão abaixo para verificar seu e-mail. (Código {{.Code}}) Se você não adicionou um novo e-mail, por favor, ignore este e-mail.
ButtonText: Verificar e-mail
VerifyPhone:
Title: ZITADEL - Verificar telefone
PreHeader: Verificar telefone
Subject: Verificar telefone
Greeting: Olá {{.DisplayName}},
Text: Um novo número de telefone foi adicionado. Por favor, use o código a seguir para verificá-lo {{.Code}}
ButtonText: Verificar telefone
VerifyEmailOTP:
Title: ZITADEL - Verifica a senha de uso único
PreHeader: Verifica a senha de uso único
Subject: Verifica a senha de uso único
Greeting: Olá {{.DisplayName}},
Text: Por favor, usa o botão 'Autenticar' ou copia a senha de uso único {{.OTP}} e cola-a na tela de autenticação para te autenticares no ZITADEL nos próximos cinco minutos.
ButtonText: Autenticar
VerifySMSOTP:
Text: >-
{{.OTP}} é sua senha única para {{ .Domain }}. Use-o nos próximos {{.Expiry}}.
@{{.Domain}} #{{.OTP}}
DomainClaimed:
Title: ZITADEL - Domínio foi reivindicado
PreHeader: Alterar e-mail / nome de usuário
Subject: Domínio foi reivindicado
Greeting: Olá {{.DisplayName}},
Text: O domínio {{.Domain}} foi reivindicado por uma organização. Seu usuário atual, {{.Username}}, não faz parte dessa organização. Portanto, você terá que alterar seu e-mail quando fizer o login. Criamos um nome de usuário temporário ({{.TempUsername}}) para este login.
ButtonText: Fazer login
PasswordlessRegistration:
Title: ZITADEL - Adicionar Login sem Senha
PreHeader: Adicionar Login sem Senha
Subject: Adicionar Login sem Senha
Greeting: Olá {{.DisplayName}},
Text: Recebemos uma solicitação para adicionar um token ou dispositivo para login sem senha. Por favor, use o botão abaixo para adicionar seu token ou dispositivo para login sem senha.
ButtonText: Adicionar Login sem Senha
PasswordChange:
Title: ZITADEL - Senha do usuário foi alterada
PreHeader: Alterar senha
Subject: Senha do usuário foi alterada
Greeting: Olá {{.DisplayName}},
Text: A senha do seu usuário foi alterada. Se esta alteração não foi feita por você, recomendamos que você redefina sua senha imediatamente.
ButtonText: Fazer login
InviteUser:
Title: Convite para {{.ApplicationName}}
PreHeader: Convite para {{.ApplicationName}}
Subject: Convite para {{.ApplicationName}}
Greeting: Olá {{.DisplayName}},
Text: Seu usuário foi convidado para {{.ApplicationName}}. Clique no botão abaixo para concluir o processo de convite. Se você não solicitou este e-mail, por favor, ignore-o.
ButtonText: Aceitar convite

View File

@@ -0,0 +1,68 @@
InitCode:
Title: Inițializare Utilizator
PreHeader: Inițializare Utilizator
Subject: Inițializare Utilizator
Greeting: Bună ziua, {{.DisplayName}},
Text: Acest utilizator a fost creat. Folosiți numele de utilizator {{.PreferredLoginName}} pentru a vă autentifica. Vă rugăm să dați clic pe butonul de mai jos pentru a finaliza procesul de inițializare. (Cod {{.Code}}) Dacă nu ați solicitat acest e-mail, vă rugăm să îl ignorați.
ButtonText: Finalizare inițializare
PasswordReset:
Title: Resetare parolă
PreHeader: Resetare parolă
Subject: Resetare parolă
Greeting: Bună ziua, {{.DisplayName}},
Text: Am primit o cerere de resetare a parolei. Vă rugăm să folosiți butonul de mai jos pentru a vă reseta parola. (Cod {{.Code}}) Dacă nu ați solicitat acest e-mail, vă rugăm să îl ignorați.
ButtonText: Resetare parolă
VerifyEmail:
Title: Verificare e-mail
PreHeader: Verificare e-mail
Subject: Verificare e-mail
Greeting: Bună ziua, {{.DisplayName}},
Text: A fost adăugat un e-mail nou. Vă rugăm să folosiți butonul de mai jos pentru a vă verifica e-mailul. (Cod {{.Code}}) Dacă nu ați adăugat un e-mail nou, vă rugăm să ignorați acest e-mail.
ButtonText: Verificare e-mail
VerifyPhone:
Title: Verificare telefon
PreHeader: Verificare telefon
Subject: Verificare telefon
Greeting: Bună ziua, {{.DisplayName}},
Text: "A fost adăugat un număr de telefon nou. Vă rugăm să folosiți următorul cod pentru a-l verifica: {{.Code}}"
ButtonText: Verificare telefon
VerifyEmailOTP:
Title: Verificare parolă unică
PreHeader: Verificare parolă unică
Subject: Verificare parolă unică
Greeting: Bună ziua, {{.DisplayName}},
Text: Vă rugăm să folosiți parola unică {{.OTP}} pentru a vă autentifica în următoarele cinci minute sau dați clic pe butonul "Autentificare".
ButtonText: Autentificare
VerifySMSOTP:
Text: >-
{{.OTP}} este parola dvs. unică pentru {{ .Domain }}. Folosiți-o în următoarele {{.Expiry}}.
@{{.Domain}} #{{.OTP}}
DomainClaimed:
Title: Domeniul a fost revendicat
PreHeader: Schimbare e-mail / nume de utilizator
Subject: Domeniul a fost revendicat
Greeting: Bună ziua, {{.DisplayName}},
Text: Domeniul {{.Domain}} a fost revendicat de o organizație. Utilizatorul dvs. actual {{.Username}} nu face parte din această organizație. Prin urmare, va trebui să vă schimbați e-mailul când vă autentificați. Am creat un nume de utilizator temporar ({{.TempUsername}}) pentru această autentificare.
ButtonText: Autentificare
PasswordlessRegistration:
Title: Adăugare autentificare fără parolă
PreHeader: Adăugare autentificare fără parolă
Subject: Adăugare autentificare fără parolă
Greeting: Bună ziua, {{.DisplayName}},
Text: Am primit o cerere de adăugare a unui token pentru autentificare fără parolă. Vă rugăm să folosiți butonul de mai jos pentru a adăuga token-ul sau dispozitivul pentru autentificare fără parolă.
ButtonText: Adăugare autentificare fără parolă
PasswordChange:
Title: Parola utilizatorului a fost schimbată
PreHeader: Schimbare parolă
Subject: Parola utilizatorului a fost schimbată
Greeting: Bună ziua, {{.DisplayName}},
Text: Parola utilizatorului dvs. a fost schimbată. Dacă această modificare nu a fost făcută de dvs., vă recomandăm să vă resetați imediat parola.
ButtonText: Autentificare
InviteUser:
Title: Invitație la {{.ApplicationName}}
PreHeader: Invitație la {{.ApplicationName}}
Subject: Invitație la {{.ApplicationName}}
Greeting: Bună ziua, {{.DisplayName}},
Text: Utilizatorul dvs. a fost invitat la {{.ApplicationName}}. Vă rugăm să dați clic pe butonul de mai jos pentru a finaliza procesul de invitație. Dacă nu ați solicitat acest e-mail, vă rugăm să îl ignorați.
ButtonText: Acceptare invitație

View File

@@ -0,0 +1,68 @@
InitCode:
Title: Регистрация пользователя
PreHeader: Регистрация пользователя
Subject: Регистрация пользователя
Greeting: Здравствуйте {{.DisplayName}},
Text: Используйте логин {{.PreferredLoginName}} для входа. Пожалуйста, нажмите кнопку ниже для завершения процесса регистрации. (Код {{.Code}}) Если вы не запрашивали это письмо, пожалуйста, проигнорируйте его.
ButtonText: Завершить регистрацию
PasswordReset:
Title: Сброс пароля
PreHeader: Сброс пароля
Subject: Сброс пароля
Greeting: Здравствуйте {{.DisplayName}},
Text: Мы получили запрос на сброс пароля. Пожалуйста, нажмите кнопку ниже для сброса вашего пароля. (Код {{.Code}}) Если вы не запрашивали это письмо, пожалуйста, проигнорируйте его.
ButtonText: Сбросить пароль
VerifyEmail:
Title: Подтверждение email
PreHeader: Подтверждение email
Subject: Подтверждение email
Greeting: Здравствуйте {{.DisplayName}},
Text: Добавлен новый email. Пожалуйста, нажмите кнопку ниже для подтверждения вашего email. (Код {{.Code}}) Если вы не запрашивали это письмо, пожалуйста, проигнорируйте его.
ButtonText: Подтвердить email
VerifyPhone:
Title: Подтверждение телефона
PreHeader: Подтверждение телефона
Subject: Подтверждение телефона
Greeting: Здравствуйте {{.DisplayName}},
Text: Добавлен новый номер телефона. Пожалуйста, используйте следующий код, чтобы подтвердить его. Код {{.Code}}
ButtonText: Подтвердить телефон
VerifyEmailOTP:
Title: Проверка одноразового пароля
PreHeader: Проверка одноразового пароля
Subject: Проверка одноразового пароля
Greeting: Привет, {{.DisplayName}}!
Text: Пожалуйста, используйте одноразовый пароль {{.OTP}} для аутентификации в течение следующих пяти минут или нажмите кнопку «Войти».
ButtonText: Войти
VerifySMSOTP:
Text: >-
{{.OTP}} — это ваш одноразовый пароль для {{ .Domain }}. Используйте его в течение следующих {{.Expiry}}.
@{{.Domain}} #{{.OTP}}
DomainClaimed:
Title: Утверждение домена
PreHeader: Изменение email / логина
Subject: Домен был утвержден
Greeting: Здравствуйте {{.DisplayName}},
Text: Домен {{.Domain}} был утвержден организацией. Ваш текущий пользователь {{.Username}} не является частью этой организации. Вам необходимо изменить свой email при входе в систему. Мы создали временный логин ({{.TempUsername}}) для входа.
ButtonText: Вход
PasswordlessRegistration:
Title: Добавление входа без пароля
PreHeader: Добавление входа без пароля
Subject: Добавление входа без пароля
Greeting: Здравствуйте {{.DisplayName}},
Text: Мы получили запрос на добавление токена для входа без пароля. Пожалуйста, используйте кнопку ниже, чтобы добавить свой токен или устройство для входа без пароля.
ButtonText: Добавить вход без пароля
PasswordChange:
Title: Смена пароля пользователя
PreHeader: Смена пароля
Subject: Пароль пользователя изменен
Greeting: Здравствуйте {{.DisplayName}},
Text: Пароль пользователя был изменен. Если это изменение сделано не вами, советуем немедленно сбросить пароль.
ButtonText: Вход
InviteUser:
Title: Приглашение в {{.ApplicationName}}
PreHeader: Приглашение в {{.ApplicationName}}
Subject: Приглашение в {{.ApplicationName}}
Greeting: Здравствуйте, {{.DisplayName}},
Text: Ваш пользователь был приглашен в {{.ApplicationName}}. Пожалуйста, нажмите кнопку ниже, чтобы завершить процесс приглашения. Если вы не запрашивали это письмо, пожалуйста, игнорируйте его.
ButtonText: Принять приглашение

View File

@@ -0,0 +1,68 @@
InitCode:
Title: Verifiera användare
PreHeader: Verifiera användare
Subject: Verifiera användare
Greeting: Hej {{.DisplayName}},
Text: Din användare har skapats. Använd användarnamnet {{.PreferredLoginName}} för att logga in. Klicka på knappen nedan för att genomföra verifieringsprocessen. (Kod {{.Code}}) Om du inte begärt det här meddelande, vänligen ignorera det.
ButtonText: Slutför verifiering
PasswordReset:
Title: Återställ lösenord
PreHeader: Återställ lösenord
Subject: Återställ lösenord
Greeting: Hej {{.DisplayName}},
Text: Vi har mottagit en begäran om att återställa lösenordet. Använd knappen nedan för att återställa ditt lösenord. (Kod {{.Code}}) Om du inte begärt det här meddelande, vänligen ignorera det.
ButtonText: Återställ lösenord
VerifyEmail:
Title: Verifiera e-post
PreHeader: Verifiera e-post
Subject: Verifiera e-post
Greeting: Hej {{.DisplayName}},
Text: En ny e-postadress har lagts till. Använd knappen nedan för att verifiera din e-post. (Kod {{.Code}}) Om du inte lagt till en ny e-postadress, vänligen ignorera detta mail.
ButtonText: Verifiera e-post
VerifyPhone:
Title: Verifiera telefon
PreHeader: Verifiera telefon
Subject: Verifiera telefon
Greeting: Hej {{.DisplayName}},
Text: Ett nytt mobilnummer har lagts till. Använd följande kod för att verifiera det {{.Code}}
ButtonText: Verifiera telefon
VerifyEmailOTP:
Title: Verifiera engångslösenord
PreHeader: Verifiera engångslösenord
Subject: Verifiera engångslösenord
Greeting: Hej {{.DisplayName}},
Text: Använd engångslösenordet {{.OTP}} för att autentisera inom de närmaste fem minuterna eller klicka på "Autentisera"-knappen.
ButtonText: Autentisera
VerifySMSOTP:
Text: >-
{{.OTP}} är ditt engångslösenord för {{ .Domain }}. Använd det inom {{.Expiry}}.
@{{.Domain}} #{{.OTP}}
DomainClaimed:
Title: Domän har blivit tagen i anspråk
PreHeader: Ändra e-post / användarnamn
Subject: Domän har blivit tagen i anspråk
Greeting: Hej {{.DisplayName}},
Text: Domänen {{.Domain}} har blivit tagen i anspråk av en organisation. Din nuvarande användare {{.Username}} är inte en del av denna organisation. Därför måste du ändra din e-post när du loggar in. Vi har skapat ett tillfälligt användarnamn ({{.TempUsername}}) för denna inloggning.
ButtonText: Logga in
PasswordlessRegistration:
Title: Lägg till lösenordsfri inloggning
PreHeader: Lägg till lösenordsfri inloggning
Subject: Lägg till lösenordsfri inloggning
Greeting: Hej {{.DisplayName}},
Text: Vi har mottagit en begäran om att lägga till en token för lösenordsfri inloggning. Använd knappen nedan för att lägga till din token eller enhet för lösenordsfri inloggning.
ButtonText: Lägg till lösenordsfri inloggning
PasswordChange:
Title: Användarens lösenord har ändrats
PreHeader: Ändra lösenord
Subject: Användarens lösenord har ändrats
Greeting: Hej {{.DisplayName}},
Text: Lösenordet för din användare har ändrats. Om denna ändring inte gjordes av dig, vänligen återställ ditt lösenord omedelbart.
ButtonText: Logga in
InviteUser:
Title: Inbjudan till {{.ApplicationName}}
PreHeader: Inbjudan till {{.ApplicationName}}
Subject: Inbjudan till {{.ApplicationName}}
Greeting: Hej {{.DisplayName}},
Text: Din användare har blivit inbjuden till {{.ApplicationName}}. Klicka på knappen nedan för att slutföra inbjudansprocessen. Om du inte har begärt detta e-postmeddelande, ignorera det.
ButtonText: Acceptera inbjudan

View File

@@ -0,0 +1,68 @@
InitCode:
Title: Kullanıcıyı Başlat
PreHeader: Kullanıcıyı Başlat
Subject: Kullanıcıyı Başlat
Greeting: Merhaba {{.DisplayName}},
Text: Bu kullanıcı oluşturuldu. Giriş yapmak için {{.PreferredLoginName}} kullanıcı adını kullanın. Başlatma işlemini tamamlamak için lütfen aşağıdaki düğmeye tıklayın. (Kod {{.Code}}) Bu e-postayı siz istemediyseniz, lütfen görmezden gelin.
ButtonText: Başlatmayı tamamla
PasswordReset:
Title: Şifre sıfırla
PreHeader: Şifre sıfırla
Subject: Şifre sıfırla
Greeting: Merhaba {{.DisplayName}},
Text: Şifre sıfırlama isteği aldık. Şifrenizi sıfırlamak için lütfen aşağıdaki düğmeyi kullanın. (Kod {{.Code}}) Bu e-postayı siz istemediyseniz, lütfen görmezden gelin.
ButtonText: Şifreyi sıfırla
VerifyEmail:
Title: E-postayı doğrula
PreHeader: E-postayı doğrula
Subject: E-postayı doğrula
Greeting: Merhaba {{.DisplayName}},
Text: Yeni bir e-posta adresi eklendi. E-posta adresinizi doğrulamak için lütfen aşağıdaki düğmeyi kullanın. (Kod {{.Code}}) Yeni bir e-posta eklemediyseniz, lütfen bu e-postayı görmezden gelin.
ButtonText: E-postayı doğrula
VerifyPhone:
Title: Telefonu doğrula
PreHeader: Telefonu doğrula
Subject: Telefonu doğrula
Greeting: Merhaba {{.DisplayName}},
Text: Yeni bir telefon numarası eklendi. Doğrulamak için lütfen şu kodu kullanın {{.Code}}
ButtonText: Telefonu doğrula
VerifyEmailOTP:
Title: Tek Kullanımlık Şifreyi Doğrula
PreHeader: Tek Kullanımlık Şifreyi Doğrula
Subject: Tek Kullanımlık Şifreyi Doğrula
Greeting: Merhaba {{.DisplayName}},
Text: Önümüzdeki beş dakika içinde kimlik doğrulaması yapmak için {{.OTP}} tek kullanımlık şifreyi kullanın veya "Kimlik Doğrula" düğmesine tıklayın.
ButtonText: Kimlik Doğrula
VerifySMSOTP:
Text: >-
{{.OTP}}, {{ .Domain }} için tek kullanımlık şifrenizdir. Önümüzdeki {{.Expiry}} içinde kullanın.
@{{.Domain}} #{{.OTP}}
DomainClaimed:
Title: Domain talep edildi
PreHeader: E-posta / kullanıcı adını değiştir
Subject: Domain talep edildi
Greeting: Merhaba {{.DisplayName}},
Text: "{{.Domain}} domaini bir organizasyon tarafından talep edildi. Mevcut kullanıcınız {{.Username}} bu organizasyonun parçası değil. Bu nedenle giriş yaparken e-postanızı değiştirmeniz gerekecek. Bu giriş için geçici bir kullanıcı adı ({{.TempUsername}}) oluşturduk."
ButtonText: Giriş Yap
PasswordlessRegistration:
Title: Şifresiz Giriş Ekle
PreHeader: Şifresiz Giriş Ekle
Subject: Şifresiz Giriş Ekle
Greeting: Merhaba {{.DisplayName}},
Text: Şifresiz giriş için token ekleme isteği aldık. Şifresiz giriş için token'ınızı veya cihazınızı eklemek için lütfen aşağıdaki düğmeyi kullanın.
ButtonText: Şifresiz Giriş Ekle
PasswordChange:
Title: Kullanıcının şifresi değişti
PreHeader: Şifre değiştir
Subject: Kullanıcının şifresi değişti
Greeting: Merhaba {{.DisplayName}},
Text: Kullanıcınızın şifresi değişti. Bu değişikliği siz yapmadıysanız, lütfen derhal şifrenizi sıfırlamanız önerilir.
ButtonText: Giriş Yap
InviteUser:
Title: "{{.ApplicationName}} için davet"
PreHeader: "{{.ApplicationName}} için davet"
Subject: "{{.ApplicationName}} için davet"
Greeting: Merhaba {{.DisplayName}},
Text: "Kullanıcınız {{.ApplicationName}} uygulamasına davet edildi. Davet işlemini tamamlamak için lütfen aşağıdaki düğmeye tıklayın. Bu e-postayı siz istemediyseniz, lütfen görmezden gelin."
ButtonText: Daveti kabul et

View File

@@ -0,0 +1,68 @@
InitCode:
Title: ZITADEL - 初始化用户
PreHeader: 初始化用户
Subject: 初始化用户
Greeting: 你好 {{.DisplayName}},
Text: 此用户是在 ZITADEL 中创建的。使用用户名 {{.PreferredLoginName}} 登录。请单击下面的按钮完成初始化过程。(代码 {{.Code}})如果不是您本人操作,请忽略它。
ButtonText: 完成初始化
PasswordReset:
Title: ZITADEL - 重置密码
PreHeader: 重置密码
Subject: 重置密码
Greeting: 你好 {{.DisplayName}},
Text: 我们收到了密码重置请求。请使用下面的按钮重置您的密码。(验证码 {{.Code}})如果不是您本人操作,请忽略它。
ButtonText: 重置密码
VerifyEmail:
Title: ZITADEL - 验证电子邮箱
PreHeader: 验证电子邮箱
Subject: 验证电子邮箱
Greeting: 你好 {{.DisplayName}},
Text: 已添加新电子邮件。请使用下面的按钮来验证您的邮件。(验证码 {{.Code}})如果不是您本人操作,请忽略此电子邮件。
ButtonText: 验证电子邮箱
VerifyPhone:
Title: ZITADEL - 验证手机号码
PreHeader: 验证手机号码
Subject: 验证手机号码
Greeting: 你好 {{.DisplayName}},
Text: 您的用户中添加了一个新的手机号码,请使用以下验证码进行验证 {{.Code}}
ButtonText: 验证手机号码
VerifyEmailOTP:
Title: ZITADEL - 验证一次性密码
PreHeader: 验证一次性密码
Subject: 验证一次性密码
Greeting: 你好,{{.DisplayName}}
Text: 请使用 '验证' 按钮,或复制一次性密码 {{.OTP}} 并将其粘贴到验证屏幕中,以在接下来的五分钟内在 ZITADEL 中进行验证。
ButtonText: 验证
VerifySMSOTP:
Text: >-
{{.OTP}} 是您的 {{ .Domain }} 的一次性密码。在下一个 {{.Expiry}} 内使用它。
@{{.Domain}} #{{.OTP}}
DomainClaimed:
Title: ZITADEL - 域名所有权验证
PreHeader: 更改电子邮件/用户名
Subject: 域名所有权验证
Greeting: 你好 {{.DisplayName}},
Text: 域 {{.Domain}} 已被组织使用。您当前的用户 {{.Username}} 不属于此组织。因此,您必须在登录时更改您的电子邮件。我们为此登录创建了一个临时用户名 ({{.TempUsername}})。
ButtonText: 登录
PasswordlessRegistration:
Title: ZITADEL - 添加无密码登录
PreHeader: 添加无密码登录
Subject: 添加无密码登录
Greeting: 你好 {{.DisplayName}},
Text: 我们收到了为无密码登录添加令牌的请求。请使用下面的按钮添加您的令牌或设备以进行无密码登录。
ButtonText: 添加无密码登录
PasswordChange:
Title: ZITADEL - 用户的密码已经改变
PreHeader: 更改密码
Subject: 用户的密码已经改变
Greeting: 你好 {{.DisplayName}},
Text: 您的用户的密码已经改变,如果这个改变不是由您做的,请注意立即重新设置您的密码。
ButtonText: 登录
InviteUser:
Title: '{{.ApplicationName}}邀请'
PreHeader: '{{.ApplicationName}}邀请'
Subject: '{{.ApplicationName}}邀请'
Greeting: 您好,{{.DisplayName}},
Text: 您的用户已被邀请加入{{.ApplicationName}}。请点击下面的按钮完成邀请过程。如果您没有请求此邮件,请忽略它。
ButtonText: 接受邀请

View File

@@ -0,0 +1,363 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>
</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a { padding:0; }
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
p { display:block;margin:13px 0; }
</style>
<!--[if mso]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 { width:100% !important; max-width: 100%; }
.mj-column-per-60 { width:60% !important; max-width: 60%; }
}
</style>
<style type="text/css">
@media only screen and (max-width:480px) {
table.mj-full-width-mobile { width: 100% !important; }
td.mj-full-width-mobile { width: auto !important; }
}
</style>
<style type="text/css">.shadow a {
box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.2), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 1px 5px 0px rgba(0, 0, 0, 0.12);
}</style>
{{if .FontURL}}
<style>
@font-face {
font-family: '{{.FontFaceFamily}}';
font-style: normal;
font-display: swap;
src: url({{.FontURL}});
}
</style>
{{end}}
</head>
<body style="word-spacing:normal;">
<div
style=""
>
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:{{.BackgroundColor}};background-color:{{.BackgroundColor}};width:100%;border-radius:16px;"
>
<tbody>
<tr>
<td>
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:800px;" width="800" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;border-radius:16px;max-width:800px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;border-radius:16px;"
>
<tbody>
<tr>
<td
style="direction:ltr;font-size:0px;padding:20px 0;padding-left:0;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="800px" ><![endif]-->
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"
>
<tbody>
<tr>
<td>
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:800px;" width="800" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:800px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"
>
<tbody>
<tr>
<td
style="direction:ltr;font-size:0px;padding:0;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="width:800px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0;line-height:0;text-align:left;display:inline-block;width:100%;direction:ltr;"
>
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td style="vertical-align:top;width:800px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%"
>
<tbody>
<tr>
<td style="vertical-align:top;padding:0;">
{{if .LogoURL}}
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="" width="100%"
>
<tbody>
<tr>
<td
align="center" style="font-size:0px;padding:50px 0 30px 0;word-break:break-word;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;"
>
<tbody>
<tr>
<td style="width:180px;">
<img
height="auto" src="{{.LogoURL}}" style="border:0;border-radius:8px;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="180"
/>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
{{end}}
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td></tr><tr><td class="" width="800px" ><![endif]-->
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"
>
<tbody>
<tr>
<td>
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:800px;" width="800" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:800px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"
>
<tbody>
<tr>
<td
style="direction:ltr;font-size:0px;padding:0;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:480px;" ><![endif]-->
<div
class="mj-column-per-60 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%"
>
<tbody>
<tr>
<td style="vertical-align:top;padding:0;">
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="" width="100%"
>
<tbody>
<tr>
<td
align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;"
>
<div
style="font-family:{{.FontFamily}};font-size:24px;font-weight:500;line-height:1;text-align:center;color:{{.FontColor}};"
>{{.Greeting}}</div>
</td>
</tr>
<tr>
<td
align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;"
>
<div
style="font-family:{{.FontFamily}};font-size:16px;font-weight:light;line-height:1.5;text-align:center;color:{{.FontColor}};"
>{{.Text}}</div>
</td>
</tr>
<tr>
<td
align="center" vertical-align="middle" class="shadow" style="font-size:0px;padding:10px 25px;word-break:break-word;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;"
>
<tr>
<td
align="center" bgcolor="{{.PrimaryColor}}" role="presentation" style="border:none;border-radius:6px;cursor:auto;mso-padding-alt:10px 25px;background:{{.PrimaryColor}};" valign="middle"
>
<a
href="{{.URL}}" rel="noopener noreferrer notrack" style="display:inline-block;background:{{.PrimaryColor}};color:#ffffff;font-family:{{.FontFamily}};font-size:14px;font-weight:500;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:6px;" target="_blank"
>
{{.ButtonText}}
</a>
</td>
</tr>
</table>
</td>
</tr>
{{if .IncludeFooter}}
<tr>
<td
align="center" style="font-size:0px;padding:10px 25px;padding-top:20px;padding-right:20px;padding-bottom:20px;padding-left:20px;word-break:break-word;"
>
<p
style="border-top:solid 2px #dbdbdb;font-size:1px;margin:0px auto;width:100%;"
>
</p>
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 2px #dbdbdb;font-size:1px;margin:0px auto;width:440px;" role="presentation" width="440px" ><tr><td style="height:0;line-height:0;"> &nbsp;
</td></tr></table><![endif]-->
</td>
</tr>
<tr>
<td
align="center" style="font-size:0px;padding:16px;word-break:break-word;"
>
<div
style="font-family:{{.FontFamily}};font-size:13px;line-height:1;text-align:center;color:{{.FontColor}};"
>{{.FooterText}}</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

View File

@@ -0,0 +1,53 @@
<mjml>
<mj-head>
<mj-attributes>
<mj-font name="Lato" href="{{.FontURL}}" />
<mj-text align="center" color="{{.FontColor}}" font-family="{{.FontFamily}}" />
<mj-section padding="0" full-width="full-width" />
<mj-body width="800px" border-radius="16px"/>
<mj-image padding="0" />
<mj-column padding="0" />
<mj-wrapper padding-left="0" full-width="full-width" />
</mj-attributes>
<mj-style>
.shadow a {
box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.2), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 1px 5px 0px rgba(0, 0, 0, 0.12);
}
</mj-style>
</mj-head>
<mj-body>
<mj-wrapper background-color="{{.BackgroundColor}}" border-radius="16px">
{{if .IncludeLogo}}
<mj-section>
<mj-group>
<mj-column>
<mj-image src="{{.LogoURL}}" align="center" width="180px" border-radius="8px" padding="50px 0 30px 0" />
</mj-column>
</mj-group>
</mj-section>
{{end}}
<mj-section>
<mj-column width="60%">
<mj-text font-size="24px" font-weight="500">{{.Greeting}}</mj-text>
<mj-text font-size="16px" line-height="1.5" font-weight="light">{{.Text}}</mj-text>
<mj-button css-class="shadow" border-radius="6px" href="{{.URL}}" rel="noopener noreferrer" background-color="{{.PrimaryColor}}" font-size="14px" font-weight="500" >{{.ButtonText}}</mj-button>
{{if .IncludeFooter}}
<mj-divider
border-color="#dbdbdb"
border-width="2px"
border-style="solid"
padding-left="20px"
padding-right="20px"
padding-bottom="20px"
padding-top="20px"
></mj-divider>
<mj-text padding="16px">
{{.FooterText}} </mj-text>
{{end}}
</mj-column>
</mj-section>
</mj-wrapper>
</mj-body>
</mjml>

View File

@@ -0,0 +1,3 @@
package statik
//go:generate go run github.com/rakyll/statik -f -src=../static -dest=.. -ns=notification

View File

@@ -0,0 +1,80 @@
package templates
import (
"bytes"
"html/template"
"io"
"net/http"
)
const (
templatesPath = "/templates"
templateFileName = "template.html"
)
func GetParsedTemplate(mailhtml string, contentData interface{}) (string, error) {
template, err := ParseTemplateFile(mailhtml, contentData)
if err != nil {
return "", err
}
return ParseTemplateText(template, contentData)
}
func ParseTemplateFile(mailhtml string, data interface{}) (string, error) {
tmpl, err := template.New("tmpl").Parse(mailhtml)
if err != nil {
return "", err
}
return parseTemplate(tmpl, data)
}
func ParseTemplateText(text string, data interface{}) (string, error) {
template, err := template.New("template").Parse(text)
if err != nil {
return "", err
}
return parseTemplate(template, data)
}
func parseTemplate(template *template.Template, data interface{}) (string, error) {
buf := new(bytes.Buffer)
if err := template.Execute(buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
func readFile(dir http.FileSystem, fileName string) (*template.Template, error) {
f, err := dir.Open(templatesPath + "/" + fileName)
if err != nil {
return nil, err
}
defer f.Close()
content, err := io.ReadAll(f)
if err != nil {
return nil, err
}
tmpl, err := template.New(fileName).Parse(string(content))
if err != nil {
return nil, err
}
return tmpl, nil
}
func readFileFromDatabase(dir http.FileSystem, fileName string) (*template.Template, error) {
f, err := dir.Open(templatesPath + "/" + fileName)
if err != nil {
return nil, err
}
defer f.Close()
content, err := io.ReadAll(f)
if err != nil {
return nil, err
}
tmpl, err := template.New(fileName).Parse(string(content))
if err != nil {
return nil, err
}
return tmpl, nil
}

View File

@@ -0,0 +1,51 @@
package templates
import (
"fmt"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/i18n"
)
const (
DefaultFontFamily = "-apple-system, BlinkMacSystemFont, Segoe UI, Lato, Arial, Helvetica, sans-serif"
DefaultFontColor = "#22292f"
DefaultBackgroundColor = "#fafafa"
DefaultPrimaryColor = "#5282C1"
)
type TemplateData struct {
Title string `json:"title,omitempty"`
PreHeader string `json:"preHeader,omitempty"`
Subject string `json:"subject,omitempty"`
Greeting string `json:"greeting,omitempty"`
Text string `json:"text,omitempty"`
URL string `json:"url,omitempty"`
ButtonText string `json:"buttonText,omitempty"`
PrimaryColor string `json:"primaryColor,omitempty"`
BackgroundColor string `json:"backgroundColor,omitempty"`
FontColor string `json:"fontColor,omitempty"`
LogoURL string `json:"logoUrl,omitempty"`
FontURL string `json:"fontUrl,omitempty"`
FontFaceFamily string `json:"fontFaceFamily,omitempty"`
FontFamily string `json:"fontFamily,omitempty"`
IncludeFooter bool `json:"includeFooter,omitempty"`
FooterText string `json:"footerText,omitempty"`
}
func (data *TemplateData) Translate(translator *i18n.Translator, msgType string, args map[string]interface{}, langs ...string) {
data.Title = translator.Localize(fmt.Sprintf("%s.%s", msgType, domain.MessageTitle), args, langs...)
data.PreHeader = translator.Localize(fmt.Sprintf("%s.%s", msgType, domain.MessagePreHeader), args, langs...)
data.Subject = translator.Localize(fmt.Sprintf("%s.%s", msgType, domain.MessageSubject), args, langs...)
data.Greeting = translator.Localize(fmt.Sprintf("%s.%s", msgType, domain.MessageGreeting), args, langs...)
data.Text = translator.Localize(fmt.Sprintf("%s.%s", msgType, domain.MessageText), args, langs...)
data.ButtonText = translator.Localize(fmt.Sprintf("%s.%s", msgType, domain.MessageButtonText), args, langs...)
// Footer text is neither included in i18n files nor defaults.yaml
footerText := fmt.Sprintf("%s.%s", msgType, domain.MessageFooterText)
data.FooterText = translator.Localize(footerText, args, langs...)
// translator returns the id of the string to be translated if no translation is found for that id
// we'll include the footer if we have a custom non-empty string and if the string doesn't include the
// id of the string that could not be translated example InitCode.Footer
data.IncludeFooter = len(data.FooterText) > 0 && data.FooterText != footerText
}

View File

@@ -0,0 +1,20 @@
package types
import (
"context"
"strings"
http_utils "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/api/ui/login"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
)
func (notify Notify) SendDomainClaimed(ctx context.Context, user *query.NotifyUser, username string) error {
url := login.LoginLink(http_utils.DomainContext(ctx).Origin(), user.ResourceOwner)
index := strings.LastIndex(user.LastEmail, "@")
args := make(map[string]interface{})
args["TempUsername"] = username
args["Domain"] = user.LastEmail[index+1:]
return notify(url, args, domain.DomainClaimedMessageType, true)
}

View File

@@ -0,0 +1,28 @@
package types
import (
"context"
"strings"
http_utils "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/api/ui/login"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
)
func (notify Notify) SendEmailVerificationCode(ctx context.Context, user *query.NotifyUser, code string, urlTmpl, authRequestID string) error {
var url string
if urlTmpl == "" {
url = login.MailVerificationLink(http_utils.DomainContext(ctx).Origin(), user.ID, code, user.ResourceOwner, authRequestID)
} else {
var buf strings.Builder
if err := domain.RenderConfirmURLTemplate(&buf, urlTmpl, user.ID, code, user.ResourceOwner); err != nil {
return err
}
url = buf.String()
}
args := make(map[string]interface{})
args["Code"] = code
return notify(url, args, domain.VerifyEmailMessageType, true)
}

View File

@@ -0,0 +1,92 @@
package types
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
http_utils "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/zerrors"
)
func TestNotify_SendEmailVerificationCode(t *testing.T) {
type args struct {
user *query.NotifyUser
origin *http_utils.DomainCtx
code string
urlTmpl string
authRequestID string
}
tests := []struct {
name string
args args
want *notifyResult
wantErr error
}{
{
name: "default URL",
args: args{
user: &query.NotifyUser{
ID: "user1",
ResourceOwner: "org1",
},
origin: &http_utils.DomainCtx{InstanceHost: "example.com", Protocol: "https"},
code: "123",
urlTmpl: "",
authRequestID: "authRequestID",
},
want: &notifyResult{
url: "https://example.com/ui/login/mail/verification?authRequestID=authRequestID&code=123&orgID=org1&userID=user1",
args: map[string]interface{}{"Code": "123"},
messageType: domain.VerifyEmailMessageType,
allowUnverifiedNotificationChannel: true,
},
},
{
name: "template error",
args: args{
user: &query.NotifyUser{
ID: "user1",
ResourceOwner: "org1",
},
origin: &http_utils.DomainCtx{InstanceHost: "example.com", Protocol: "https"},
code: "123",
urlTmpl: "{{",
authRequestID: "authRequestID",
},
want: &notifyResult{},
wantErr: zerrors.ThrowInvalidArgument(nil, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate"),
},
{
name: "template success",
args: args{
user: &query.NotifyUser{
ID: "user1",
ResourceOwner: "org1",
},
origin: &http_utils.DomainCtx{InstanceHost: "example.com", Protocol: "https"},
code: "123",
urlTmpl: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}",
authRequestID: "authRequestID",
},
want: &notifyResult{
url: "https://example.com/email/verify?userID=user1&code=123&orgID=org1",
args: map[string]interface{}{"Code": "123"},
messageType: domain.VerifyEmailMessageType,
allowUnverifiedNotificationChannel: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, notify := mockNotify()
err := notify.SendEmailVerificationCode(http_utils.WithDomainContext(context.Background(), tt.args.origin), tt.args.user, tt.args.code, tt.args.urlTmpl, tt.args.authRequestID)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, got)
})
}
}

View File

@@ -0,0 +1,17 @@
package types
import (
"context"
http_utils "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/api/ui/login"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
)
func (notify Notify) SendUserInitCode(ctx context.Context, user *query.NotifyUser, code, authRequestID string) error {
url := login.InitUserLink(http_utils.DomainContext(ctx).Origin(), user.ID, user.PreferredLoginName, code, user.ResourceOwner, user.PasswordSet, authRequestID)
args := make(map[string]interface{})
args["Code"] = code
return notify(url, args, domain.InitCodeMessageType, true)
}

View File

@@ -0,0 +1,31 @@
package types
import (
"context"
"strings"
http_utils "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/api/ui/login"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
)
func (notify Notify) SendInviteCode(ctx context.Context, user *query.NotifyUser, code, applicationName, urlTmpl, authRequestID string) error {
var url string
if applicationName == "" {
applicationName = "ZITADEL"
}
if urlTmpl == "" {
url = login.InviteUserLink(http_utils.DomainContext(ctx).Origin(), user.ID, user.PreferredLoginName, code, user.ResourceOwner, authRequestID)
} else {
var buf strings.Builder
if err := domain.RenderConfirmURLTemplate(&buf, urlTmpl, user.ID, code, user.ResourceOwner); err != nil {
return err
}
url = buf.String()
}
args := make(map[string]interface{})
args["Code"] = code
args["ApplicationName"] = applicationName
return notify(url, args, domain.InviteUserMessageType, true)
}

View File

@@ -0,0 +1,171 @@
package types
import (
"context"
"html"
"strings"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/i18n"
"github.com/zitadel/zitadel/internal/notification/channels/email"
"github.com/zitadel/zitadel/internal/notification/channels/set"
"github.com/zitadel/zitadel/internal/notification/channels/sms"
"github.com/zitadel/zitadel/internal/notification/channels/webhook"
"github.com/zitadel/zitadel/internal/notification/senders"
"github.com/zitadel/zitadel/internal/notification/templates"
"github.com/zitadel/zitadel/internal/query"
)
type Notify func(
url string,
args map[string]interface{},
messageType string,
allowUnverifiedNotificationChannel bool,
) error
type ChannelChains interface {
Email(context.Context) (*senders.Chain, *email.Config, error)
SMS(context.Context) (*senders.Chain, *sms.Config, error)
Webhook(context.Context, webhook.Config) (*senders.Chain, error)
SecurityTokenEvent(context.Context, set.Config) (*senders.Chain, error)
}
func SendEmail(
ctx context.Context,
channels ChannelChains,
mailhtml string,
translator *i18n.Translator,
user *query.NotifyUser,
colors *query.LabelPolicy,
triggeringEventType eventstore.EventType,
) Notify {
return func(
urlTmpl string,
args map[string]interface{},
messageType string,
allowUnverifiedNotificationChannel bool,
) error {
args = mapNotifyUserToArgs(user, args)
sanitizeArgsForHTML(args)
url, err := urlFromTemplate(urlTmpl, args)
if err != nil {
return err
}
data := GetTemplateData(ctx, translator, args, url, messageType, user.PreferredLanguage.String(), colors)
template, err := templates.GetParsedTemplate(mailhtml, data)
if err != nil {
return err
}
return generateEmail(
ctx,
channels,
user,
template,
data,
args,
allowUnverifiedNotificationChannel,
triggeringEventType,
)
}
}
func sanitizeArgsForHTML(args map[string]any) {
for key, arg := range args {
switch a := arg.(type) {
case string:
args[key] = html.EscapeString(a)
case []string:
for i, s := range a {
a[i] = html.EscapeString(s)
}
case database.TextArray[string]:
for i, s := range a {
a[i] = html.EscapeString(s)
}
}
}
}
func urlFromTemplate(urlTmpl string, args map[string]interface{}) (string, error) {
var buf strings.Builder
if err := domain.RenderURLTemplate(&buf, urlTmpl, args); err != nil {
return "", err
}
return buf.String(), nil
}
func SendSMS(
ctx context.Context,
channels ChannelChains,
translator *i18n.Translator,
user *query.NotifyUser,
colors *query.LabelPolicy,
triggeringEventType eventstore.EventType,
instanceID string,
jobID string,
generatorInfo *senders.CodeGeneratorInfo,
) Notify {
return func(
urlTmpl string,
args map[string]interface{},
messageType string,
allowUnverifiedNotificationChannel bool,
) error {
args = mapNotifyUserToArgs(user, args)
url, err := urlFromTemplate(urlTmpl, args)
if err != nil {
return err
}
data := GetTemplateData(ctx, translator, args, url, messageType, user.PreferredLanguage.String(), colors)
return generateSms(
ctx,
channels,
user,
data,
args,
allowUnverifiedNotificationChannel,
triggeringEventType,
instanceID,
jobID,
generatorInfo,
)
}
}
func SendJSON(
ctx context.Context,
webhookConfig webhook.Config,
channels ChannelChains,
serializable interface{},
triggeringEventType eventstore.EventType,
) Notify {
return func(_ string, _ map[string]interface{}, _ string, _ bool) error {
return handleWebhook(
ctx,
webhookConfig,
channels,
serializable,
triggeringEventType,
)
}
}
func SendSecurityTokenEvent(
ctx context.Context,
setConfig set.Config,
channels ChannelChains,
token any,
triggeringEventType eventstore.EventType,
) Notify {
return func(_ string, _ map[string]interface{}, _ string, _ bool) error {
return handleSecurityTokenEvent(
ctx,
setConfig,
channels,
token,
triggeringEventType,
)
}
}

View File

@@ -0,0 +1,29 @@
package types
import (
"context"
"time"
http_utils "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/domain"
)
func (notify Notify) SendOTPSMSCode(ctx context.Context, code string, expiry time.Duration) error {
args := otpArgs(ctx, code, expiry)
return notify("", args, domain.VerifySMSOTPMessageType, false)
}
func (notify Notify) SendOTPEmailCode(ctx context.Context, url, code string, expiry time.Duration) error {
args := otpArgs(ctx, code, expiry)
return notify(url, args, domain.VerifyEmailOTPMessageType, false)
}
func otpArgs(ctx context.Context, code string, expiry time.Duration) map[string]interface{} {
domainCtx := http_utils.DomainContext(ctx)
args := make(map[string]interface{})
args["OTP"] = code
args["Origin"] = domainCtx.Origin()
args["Domain"] = domainCtx.RequestedDomain()
args["Expiry"] = expiry
return args
}

View File

@@ -0,0 +1,16 @@
package types
import (
"context"
http_utils "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/api/ui/console"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
)
func (notify Notify) SendPasswordChange(ctx context.Context, user *query.NotifyUser) error {
url := console.LoginHintLink(http_utils.DomainContext(ctx).Origin(), user.PreferredLoginName)
args := make(map[string]interface{})
return notify(url, args, domain.PasswordChangeMessageType, true)
}

View File

@@ -0,0 +1,27 @@
package types
import (
"context"
"strings"
http_utils "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/api/ui/login"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
)
func (notify Notify) SendPasswordCode(ctx context.Context, user *query.NotifyUser, code, urlTmpl, authRequestID string) error {
var url string
if urlTmpl == "" {
url = login.InitPasswordLink(http_utils.DomainContext(ctx).Origin(), user.ID, code, user.ResourceOwner, authRequestID)
} else {
var buf strings.Builder
if err := domain.RenderConfirmURLTemplate(&buf, urlTmpl, user.ID, code, user.ResourceOwner); err != nil {
return err
}
url = buf.String()
}
args := make(map[string]interface{})
args["Code"] = code
return notify(url, args, domain.PasswordResetMessageType, true)
}

View File

@@ -0,0 +1,25 @@
package types
import (
"context"
"strings"
http_utils "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/api/ui/login"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
)
func (notify Notify) SendPasswordlessRegistrationLink(ctx context.Context, user *query.NotifyUser, code, codeID, urlTmpl string) error {
var url string
if urlTmpl == "" {
url = domain.PasswordlessInitCodeLink(http_utils.DomainContext(ctx).Origin()+login.HandlerPrefix+login.EndpointPasswordlessRegistration, user.ID, user.ResourceOwner, codeID, code)
} else {
var buf strings.Builder
if err := domain.RenderPasskeyURLTemplate(&buf, urlTmpl, user.ID, user.ResourceOwner, codeID, code); err != nil {
return err
}
url = buf.String()
}
return notify(url, nil, domain.PasswordlessRegistrationMessageType, true)
}

Some files were not shown because too many files have changed in this diff Show More