feat: add stdout and filesystem notification channels (#2925)

* feat: add filesystem and stdout notification channels

* configure through env vars

* compile

* feat: add compact option for debug notification channels

* fix channel mock generation

* avoid sensitive information in error message

Co-authored-by: Livio Amstutz <livio.a@gmail.com>

* add review improvements

Co-authored-by: Livio Amstutz <livio.a@gmail.com>
This commit is contained in:
Elio Bischof
2022-01-06 09:00:24 +01:00
committed by GitHub
parent 2bbbc3551a
commit aa2a1848da
37 changed files with 426 additions and 247 deletions

View File

@@ -0,0 +1,17 @@
package channels
type Message interface {
GetContent() string
}
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

@@ -3,57 +3,48 @@ package chat
import (
"bytes"
"encoding/json"
"github.com/caos/logging"
caos_errs "github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/notification/providers"
"io/ioutil"
"net/http"
"net/url"
"unicode/utf8"
"github.com/k3a/html2text"
"github.com/caos/logging"
caos_errs "github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/notification/channels"
)
type Chat struct {
URL *url.URL
SplitCount int
}
func InitChatChannel(config ChatConfig) (channels.NotificationChannel, error) {
func InitChatProvider(config ChatConfig) (*Chat, error) {
url, err := url.Parse(config.Url)
if err != nil {
return nil, err
}
return &Chat{
URL: url,
SplitCount: config.SplitCount,
}, nil
}
func (chat *Chat) CanHandleMessage(_ providers.Message) bool {
return true
}
logging.Log("NOTIF-kSvPp").Debug("successfully initialized chat email and sms channel")
func (chat *Chat) HandleMessage(message providers.Message) error {
contentText := message.GetContent()
for _, splittedMsg := range splitMessage(contentText, chat.SplitCount) {
chatMsg := &ChatMessage{Text: splittedMsg}
if err := chat.SendMessage(chatMsg); err != nil {
return err
return channels.HandleMessageFunc(func(message channels.Message) error {
contentText := message.GetContent()
if config.Compact {
contentText = html2text.HTML2Text(contentText)
}
}
return nil
for _, splittedMsg := range splitMessage(contentText, config.SplitCount) {
if err := sendMessage(splittedMsg, url); err != nil {
return err
}
}
return nil
}), nil
}
func (chat *Chat) SendMessage(message providers.Message) error {
chatMsg, ok := message.(*ChatMessage)
if !ok {
return caos_errs.ThrowInternal(nil, "EMAIL-s8JLs", "message is not ChatMessage")
}
req, err := json.Marshal(chatMsg)
func sendMessage(message string, chatUrl *url.URL) error {
req, err := json.Marshal(message)
if err != nil {
return caos_errs.ThrowInternal(err, "PROVI-s8uie", "Could not unmarshal content")
}
response, err := http.Post(chat.URL.String(), "application/json; charset=UTF-8", bytes.NewReader(req))
response, err := http.Post(chatUrl.String(), "application/json; charset=UTF-8", bytes.NewReader(req))
if err != nil {
return caos_errs.ThrowInternal(err, "PROVI-si93s", "unable to send message")
}

View File

@@ -0,0 +1,9 @@
package chat
type ChatConfig struct {
// Defaults to true if DebugMode is set to true
Enabled *bool
Url string
SplitCount int
Compact bool
}

View File

@@ -0,0 +1,51 @@
package fs
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/caos/logging"
caos_errors "github.com/caos/zitadel/internal/errors"
"github.com/k3a/html2text"
"github.com/caos/zitadel/internal/notification/channels"
"github.com/caos/zitadel/internal/notification/messages"
)
func InitFSChannel(config FSConfig) (channels.NotificationChannel, error) {
if err := os.MkdirAll(config.Path, os.ModePerm); err != nil {
return nil, err
}
logging.Log("NOTIF-kSvPp").Debug("successfully initialized filesystem email and sms channel")
return channels.HandleMessageFunc(func(message channels.Message) error {
fileName := fmt.Sprintf("%d_", time.Now().Unix())
content := message.GetContent()
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"
default:
return caos_errors.ThrowUnimplementedf(nil, "NOTIF-6f9a1", "filesystem provider doesn't support message type %T", message)
}
return ioutil.WriteFile(filepath.Join(config.Path, fileName), []byte(content), 0666)
}), nil
}

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
package log
import (
"fmt"
"github.com/k3a/html2text"
"github.com/caos/logging"
"github.com/caos/zitadel/internal/notification/channels"
)
func InitStdoutChannel(config LogConfig) channels.NotificationChannel {
logging.Log("NOTIF-D0164").Debug("successfully initialized stdout email and sms channel")
return channels.HandleMessageFunc(func(message channels.Message) error {
content := message.GetContent()
if config.Compact {
content = html2text.HTML2Text(content)
}
logging.Log("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 LogConfig struct {
Enabled bool
Compact bool
}

View File

@@ -0,0 +1,48 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/caos/zitadel/internal/notification/channels (interfaces: NotificationChannel)
// Package mock is a generated GoMock package.
package mock
import (
channels "github.com/caos/zitadel/internal/notification/channels"
gomock "github.com/golang/mock/gomock"
reflect "reflect"
)
// 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 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandleMessage", reflect.TypeOf((*MockNotificationChannel)(nil).HandleMessage), arg0)
}

View File

@@ -1,5 +1,5 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/caos/zitadel/internal/notification/providers (interfaces: Message)
// Source: github.com/caos/zitadel/internal/notification/channels (interfaces: Message)
// Package mock is a generated GoMock package.
package mock

View File

@@ -1,44 +1,48 @@
package email
package smtp
import (
"crypto/tls"
"net"
"net/smtp"
"github.com/caos/zitadel/internal/notification/messages"
"github.com/caos/logging"
caos_errs "github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/notification/providers"
"github.com/caos/zitadel/internal/notification/channels"
"github.com/pkg/errors"
)
var _ channels.NotificationChannel = (*Email)(nil)
type Email struct {
smtpClient *smtp.Client
}
func InitEmailProvider(config EmailConfig) (*Email, error) {
func InitSMTPChannel(config EmailConfig) (*Email, error) {
client, err := config.SMTP.connectToSMTP(config.Tls)
if err != nil {
return nil, err
}
logging.Log("NOTIF-4n4Ih").Debug("successfully initialized smtp email channel")
return &Email{
smtpClient: client,
}, nil
}
func (email *Email) CanHandleMessage(message providers.Message) bool {
msg, ok := message.(*EmailMessage)
if !ok {
return false
}
return msg.Content != "" && msg.Subject != "" && len(msg.Recipients) > 0
}
func (email *Email) HandleMessage(message providers.Message) error {
func (email *Email) HandleMessage(message channels.Message) error {
defer email.smtpClient.Close()
emailMsg, ok := message.(*EmailMessage)
emailMsg, ok := message.(*messages.Email)
if !ok {
return caos_errs.ThrowInternal(nil, "EMAIL-s8JLs", "message is not EmailMessage")
}
if emailMsg.Content == "" || emailMsg.Subject == "" || len(emailMsg.Recipients) == 0 {
return caos_errs.ThrowInternalf(nil, "EMAIL-zGemZ", "subject, recipients and content must be set but got subject %s, recipients length %d and content length %d", emailMsg.Subject, len(emailMsg.Recipients), len(emailMsg.Content))
}
// To && From
if err := email.smtpClient.Mail(emailMsg.SenderEmail); err != nil {
return caos_errs.ThrowInternalf(err, "EMAIL-s3is3", "could not set sender: %v", emailMsg.SenderEmail)

View File

@@ -1,4 +1,4 @@
package email
package smtp
type EmailConfig struct {
SMTP SMTP

View File

@@ -0,0 +1,28 @@
package twilio
import (
"github.com/caos/logging"
caos_errs "github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/notification/channels"
"github.com/caos/zitadel/internal/notification/messages"
twilio "github.com/kevinburke/twilio-go"
)
func InitTwilioChannel(config TwilioConfig) channels.NotificationChannel {
client := twilio.NewClient(config.SID, config.Token, nil)
logging.Log("NOTIF-KaxDZ").Debug("successfully initialized twilio sms channel")
return channels.HandleMessageFunc(func(message channels.Message) error {
twilioMsg, ok := message.(*messages.SMS)
if !ok {
return caos_errs.ThrowInternal(nil, "TWILI-s0pLc", "message is not SMS")
}
m, err := client.Messages.SendMessage(twilioMsg.SenderPhoneNumber, twilioMsg.RecipientPhoneNumber, twilioMsg.GetContent(), nil)
if err != nil {
return caos_errs.ThrowInternal(err, "TWILI-osk3S", "could not send message")
}
logging.LogWithFields("SMS_-f335c523", "message_sid", m.Sid, "status", m.Status).Debug("sms sent")
return nil
})
}

View File

@@ -1,9 +1,11 @@
package email
package messages
import (
"fmt"
"regexp"
"strings"
"github.com/caos/zitadel/internal/notification/channels"
)
var (
@@ -11,7 +13,9 @@ var (
lineBreak = "\r\n"
)
type EmailMessage struct {
var _ channels.Message = (*Email)(nil)
type Email struct {
Recipients []string
BCC []string
CC []string
@@ -20,7 +24,7 @@ type EmailMessage struct {
Content string
}
func (msg *EmailMessage) GetContent() string {
func (msg *Email) GetContent() string {
headers := make(map[string]string)
headers["From"] = msg.SenderEmail
headers["To"] = strings.Join(msg.Recipients, ", ")

View File

@@ -0,0 +1,15 @@
package messages
import "github.com/caos/zitadel/internal/notification/channels"
var _ channels.Message = (*SMS)(nil)
type SMS struct {
SenderPhoneNumber string
RecipientPhoneNumber string
Content string
}
func (msg *SMS) GetContent() string {
return msg.Content
}

View File

@@ -1,6 +0,0 @@
package chat
type ChatConfig struct {
Url string
SplitCount int
}

View File

@@ -1,9 +0,0 @@
package chat
type ChatMessage struct {
Text string `json:"text"`
}
func (msg *ChatMessage) GetContent() string {
return msg.Text
}

View File

@@ -1,4 +0,0 @@
package providers
//go:generate mockgen -package mock -destination ./mock/provider.mock.go github.com/caos/zitadel/internal/notification/providers NotificationProvider
//go:generate mockgen -package mock -destination ./mock/message.mock.go github.com/caos/zitadel/internal/notification/providers Message

View File

@@ -1,5 +0,0 @@
package providers
type Message interface {
GetContent() string
}

View File

@@ -1,61 +0,0 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/caos/zitadel/internal/notification/providers (interfaces: NotificationProvider)
// Package mock is a generated GoMock package.
package mock
import (
gomock "github.com/golang/mock/gomock"
reflect "reflect"
)
// MockNotificationProvider is a mock of NotificationProvider interface
type MockNotificationProvider struct {
ctrl *gomock.Controller
recorder *MockNotificationProviderMockRecorder
}
// MockNotificationProviderMockRecorder is the mock recorder for MockNotificationProvider
type MockNotificationProviderMockRecorder struct {
mock *MockNotificationProvider
}
// NewMockNotificationProvider creates a new mock instance
func NewMockNotificationProvider(ctrl *gomock.Controller) *MockNotificationProvider {
mock := &MockNotificationProvider{ctrl: ctrl}
mock.recorder = &MockNotificationProviderMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockNotificationProvider) EXPECT() *MockNotificationProviderMockRecorder {
return m.recorder
}
// CanHandleMessage mocks base method
func (m *MockNotificationProvider) CanHandleMessage() bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CanHandleMessage")
ret0, _ := ret[0].(bool)
return ret0
}
// CanHandleMessage indicates an expected call of CanHandleMessage
func (mr *MockNotificationProviderMockRecorder) CanHandleMessage() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CanHandleMessage", reflect.TypeOf((*MockNotificationProvider)(nil).CanHandleMessage))
}
// HandleMessage mocks base method
func (m *MockNotificationProvider) HandleMessage() error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "HandleMessage")
ret0, _ := ret[0].(error)
return ret0
}
// HandleMessage indicates an expected call of HandleMessage
func (mr *MockNotificationProviderMockRecorder) HandleMessage() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandleMessage", reflect.TypeOf((*MockNotificationProvider)(nil).HandleMessage))
}

View File

@@ -1,6 +0,0 @@
package providers
type NotificationProvider interface {
CanHandleMessage() bool
HandleMessage() error
}

View File

@@ -1,11 +0,0 @@
package twilio
type TwilioMessage struct {
SenderPhoneNumber string
RecipientPhoneNumber string
Content string
}
func (msg *TwilioMessage) GetContent() string {
return msg.Content
}

View File

@@ -1,39 +0,0 @@
package twilio
import (
"github.com/caos/logging"
caos_errs "github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/notification/providers"
twilio "github.com/kevinburke/twilio-go"
)
type Twilio struct {
client *twilio.Client
}
func InitTwilioProvider(config TwilioConfig) *Twilio {
return &Twilio{
client: twilio.NewClient(config.SID, config.Token, nil),
}
}
func (t *Twilio) CanHandleMessage(message providers.Message) bool {
twilioMsg, ok := message.(*TwilioMessage)
if !ok {
return false
}
return twilioMsg.Content != "" && twilioMsg.RecipientPhoneNumber != "" && twilioMsg.SenderPhoneNumber != ""
}
func (t *Twilio) HandleMessage(message providers.Message) error {
twilioMsg, ok := message.(*TwilioMessage)
if !ok {
return caos_errs.ThrowInternal(nil, "TWILI-s0pLc", "message is not TwilioMessage")
}
m, err := t.client.Messages.SendMessage(twilioMsg.SenderPhoneNumber, twilioMsg.RecipientPhoneNumber, twilioMsg.GetContent(), nil)
if err != nil {
return caos_errs.ThrowInternal(err, "TWILI-osk3S", "could not send message")
}
logging.LogWithFields("SMS_-f335c523", "message_sid", m.Sid, "status", m.Status).Debug("sms sent")
return nil
}

View File

@@ -0,0 +1,24 @@
package senders
import "github.com/caos/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
}

View File

@@ -0,0 +1,46 @@
package senders
import (
"github.com/caos/zitadel/internal/config/systemdefaults"
"github.com/caos/zitadel/internal/notification/channels"
"github.com/caos/zitadel/internal/notification/channels/chat"
"github.com/caos/zitadel/internal/notification/channels/fs"
"github.com/caos/zitadel/internal/notification/channels/log"
)
func debugChannels(config systemdefaults.Notifications) (channels.NotificationChannel, error) {
var (
providers []channels.NotificationChannel
enableChat bool
)
if config.Providers.Chat.Enabled != nil {
enableChat = *config.Providers.Chat.Enabled
} else {
// ensures backward compatible configuration
enableChat = config.DebugMode
}
if enableChat {
p, err := chat.InitChatChannel(config.Providers.Chat)
if err != nil {
return nil, err
}
providers = append(providers, p)
}
if config.Providers.FileSystem.Enabled {
p, err := fs.InitFSChannel(config.Providers.FileSystem)
if err != nil {
return nil, err
}
providers = append(providers, p)
}
if config.Providers.Log.Enabled {
providers = append(providers, log.InitStdoutChannel(config.Providers.Log))
}
return chainChannels(providers...), nil
}

View File

@@ -0,0 +1,25 @@
package senders
import (
"github.com/caos/zitadel/internal/config/systemdefaults"
"github.com/caos/zitadel/internal/notification/channels"
"github.com/caos/zitadel/internal/notification/channels/smtp"
)
func EmailChannels(config systemdefaults.Notifications) (channels.NotificationChannel, error) {
debug, err := debugChannels(config)
if err != nil {
return nil, err
}
if !config.DebugMode {
p, err := smtp.InitSMTPChannel(config.Providers.Email)
if err != nil {
return nil, err
}
return chainChannels(debug, p), nil
}
return debug, nil
}

View File

@@ -0,0 +1,21 @@
package senders
import (
"github.com/caos/zitadel/internal/config/systemdefaults"
"github.com/caos/zitadel/internal/notification/channels"
"github.com/caos/zitadel/internal/notification/channels/twilio"
)
func SMSChannels(config systemdefaults.Notifications) (channels.NotificationChannel, error) {
debug, err := debugChannels(config)
if err != nil {
return nil, err
}
if !config.DebugMode {
return chainChannels(debug, twilio.InitTwilioChannel(config.Providers.Twilio)), nil
}
return debug, nil
}

View File

@@ -3,21 +3,16 @@ package types
import (
"html"
"github.com/caos/zitadel/internal/notification/messages"
"github.com/caos/zitadel/internal/notification/senders"
"github.com/caos/zitadel/internal/config/systemdefaults"
caos_errs "github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/notification/providers"
"github.com/caos/zitadel/internal/notification/providers/chat"
"github.com/caos/zitadel/internal/notification/providers/email"
view_model "github.com/caos/zitadel/internal/user/repository/view/model"
)
func generateEmail(user *view_model.NotifyUser, subject, content string, config systemdefaults.Notifications, lastEmail bool) error {
provider, err := email.InitEmailProvider(config.Providers.Email)
if err != nil {
return err
}
content = html.UnescapeString(content)
message := &email.EmailMessage{
message := &messages.Email{
SenderEmail: config.Providers.Email.From,
Recipients: []string{user.VerifiedEmail},
Subject: subject,
@@ -26,21 +21,13 @@ func generateEmail(user *view_model.NotifyUser, subject, content string, config
if lastEmail {
message.Recipients = []string{user.LastEmail}
}
if provider.CanHandleMessage(message) {
if config.DebugMode {
return sendDebugEmail(message, config)
}
return provider.HandleMessage(message)
}
return caos_errs.ThrowInternalf(nil, "NOTIF-s8ipw", "Could not send init message: userid: %v", user.ID)
}
func sendDebugEmail(message providers.Message, config systemdefaults.Notifications) error {
provider, err := chat.InitChatProvider(config.Providers.Chat)
channels, err := senders.EmailChannels(config)
if err != nil {
return err
}
return provider.HandleMessage(message)
return channels.HandleMessage(message)
}
func mapNotifyUserToArgs(user *view_model.NotifyUser) map[string]interface{} {

View File

@@ -2,16 +2,13 @@ package types
import (
"github.com/caos/zitadel/internal/config/systemdefaults"
caos_errs "github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/notification/providers"
"github.com/caos/zitadel/internal/notification/providers/chat"
"github.com/caos/zitadel/internal/notification/providers/twilio"
"github.com/caos/zitadel/internal/notification/messages"
"github.com/caos/zitadel/internal/notification/senders"
view_model "github.com/caos/zitadel/internal/user/repository/view/model"
)
func generateSms(user *view_model.NotifyUser, content string, config systemdefaults.Notifications, lastPhone bool) error {
provider := twilio.InitTwilioProvider(config.Providers.Twilio)
message := &twilio.TwilioMessage{
message := &messages.SMS{
SenderPhoneNumber: config.Providers.Twilio.From,
RecipientPhoneNumber: user.VerifiedPhone,
Content: content,
@@ -19,19 +16,10 @@ func generateSms(user *view_model.NotifyUser, content string, config systemdefau
if lastPhone {
message.RecipientPhoneNumber = user.LastPhone
}
if provider.CanHandleMessage(message) {
if config.DebugMode {
return sendDebugPhone(message, config)
}
return provider.HandleMessage(message)
}
return caos_errs.ThrowInternalf(nil, "NOTIF-s8ipw", "Could not send init message: userid: %v", user.ID)
}
func sendDebugPhone(message providers.Message, config systemdefaults.Notifications) error {
provider, err := chat.InitChatProvider(config.Providers.Chat)
channels, err := senders.SMSChannels(config)
if err != nil {
return err
}
return provider.HandleMessage(message)
return channels.HandleMessage(message)
}