mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-12 01:37:31 +00:00
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:
17
internal/notification/channels/channel.go
Normal file
17
internal/notification/channels/channel.go
Normal 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)
|
||||
}
|
77
internal/notification/channels/chat/channel.go
Normal file
77
internal/notification/channels/chat/channel.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"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"
|
||||
)
|
||||
|
||||
func InitChatChannel(config ChatConfig) (channels.NotificationChannel, error) {
|
||||
|
||||
url, err := url.Parse(config.Url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logging.Log("NOTIF-kSvPp").Debug("successfully initialized chat email and sms channel")
|
||||
|
||||
return channels.HandleMessageFunc(func(message channels.Message) error {
|
||||
contentText := message.GetContent()
|
||||
if config.Compact {
|
||||
contentText = html2text.HTML2Text(contentText)
|
||||
}
|
||||
for _, splittedMsg := range splitMessage(contentText, config.SplitCount) {
|
||||
if err := sendMessage(splittedMsg, url); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}), nil
|
||||
}
|
||||
|
||||
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(chatUrl.String(), "application/json; charset=UTF-8", bytes.NewReader(req))
|
||||
if err != nil {
|
||||
return caos_errs.ThrowInternal(err, "PROVI-si93s", "unable to send message")
|
||||
}
|
||||
if response.StatusCode != 200 {
|
||||
defer response.Body.Close()
|
||||
bodyBytes, err := ioutil.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return caos_errs.ThrowInternal(err, "PROVI-PSLd3", "unable to read response message")
|
||||
}
|
||||
logging.LogWithFields("PROVI-PS0kx", "Body", string(bodyBytes)).Warn("Chat Message post didnt get 200 OK")
|
||||
return caos_errs.ThrowInternal(nil, "PROVI-LSopw", string(bodyBytes))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitMessage(message string, count int) []string {
|
||||
if count == 0 {
|
||||
return []string{message}
|
||||
}
|
||||
var splits []string
|
||||
var l, r int
|
||||
for l, r = 0, count; r < len(message); l, r = r, r+count {
|
||||
for !utf8.RuneStart(message[r]) {
|
||||
r--
|
||||
}
|
||||
splits = append(splits, message[l:r])
|
||||
}
|
||||
splits = append(splits, message[l:])
|
||||
return splits
|
||||
}
|
9
internal/notification/channels/chat/config.go
Normal file
9
internal/notification/channels/chat/config.go
Normal 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
|
||||
}
|
51
internal/notification/channels/fs/channel.go
Normal file
51
internal/notification/channels/fs/channel.go
Normal 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
|
||||
}
|
7
internal/notification/channels/fs/config.go
Normal file
7
internal/notification/channels/fs/config.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package fs
|
||||
|
||||
type FSConfig struct {
|
||||
Enabled bool
|
||||
Path string
|
||||
Compact bool
|
||||
}
|
4
internal/notification/channels/gen_mock.go
Normal file
4
internal/notification/channels/gen_mock.go
Normal 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
|
29
internal/notification/channels/log/channel.go
Normal file
29
internal/notification/channels/log/channel.go
Normal 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
|
||||
})
|
||||
}
|
6
internal/notification/channels/log/config.go
Normal file
6
internal/notification/channels/log/config.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package log
|
||||
|
||||
type LogConfig struct {
|
||||
Enabled bool
|
||||
Compact bool
|
||||
}
|
48
internal/notification/channels/mock/channel.mock.go
Normal file
48
internal/notification/channels/mock/channel.mock.go
Normal 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)
|
||||
}
|
47
internal/notification/channels/mock/message.mock.go
Normal file
47
internal/notification/channels/mock/message.mock.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/caos/zitadel/internal/notification/channels (interfaces: Message)
|
||||
|
||||
// Package mock is a generated GoMock package.
|
||||
package mock
|
||||
|
||||
import (
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
reflect "reflect"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetContent")
|
||||
ret0, _ := ret[0].(string)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
149
internal/notification/channels/smtp/channel.go
Normal file
149
internal/notification/channels/smtp/channel.go
Normal file
@@ -0,0 +1,149 @@
|
||||
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/channels"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var _ channels.NotificationChannel = (*Email)(nil)
|
||||
|
||||
type Email struct {
|
||||
smtpClient *smtp.Client
|
||||
}
|
||||
|
||||
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) HandleMessage(message channels.Message) error {
|
||||
defer email.smtpClient.Close()
|
||||
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)
|
||||
}
|
||||
|
||||
for _, recp := range append(append(emailMsg.Recipients, emailMsg.CC...), emailMsg.BCC...) {
|
||||
if err := email.smtpClient.Rcpt(recp); err != nil {
|
||||
return caos_errs.ThrowInternalf(err, "EMAIL-s4is4", "could not set recipient: %v", recp)
|
||||
}
|
||||
}
|
||||
|
||||
// Data
|
||||
w, err := email.smtpClient.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = w.Write([]byte(emailMsg.GetContent()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer logging.LogWithFields("EMAI-a1c87ec8").Debug("email sent")
|
||||
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, caos_errs.ThrowInternal(err, "EMAIL-spR56", "could not split host and port for connect to smtp")
|
||||
}
|
||||
|
||||
if !tlsRequired {
|
||||
client, err = smtpConfig.getSMPTClient()
|
||||
} else {
|
||||
client, err = smtpConfig.getSMPTClientWithTls(host)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = smtpConfig.smtpAuth(client, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (smtpConfig SMTP) getSMPTClient() (*smtp.Client, error) {
|
||||
client, err := smtp.Dial(smtpConfig.Host)
|
||||
if err != nil {
|
||||
return nil, caos_errs.ThrowInternal(err, "EMAIL-skwos", "could not make smtp dial")
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (smtpConfig SMTP) getSMPTClientWithTls(host string) (*smtp.Client, error) {
|
||||
conn, err := tls.Dial("tcp", smtpConfig.Host, &tls.Config{})
|
||||
|
||||
if errors.As(err, &tls.RecordHeaderError{}) {
|
||||
logging.Log("MAIN-xKIzT").OnError(err).Warn("could not connect using normal tls. trying starttls instead...")
|
||||
return smtpConfig.getSMPTClientWithStartTls(host)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, caos_errs.ThrowInternal(err, "EMAIL-sl39s", "could not make tls dial")
|
||||
}
|
||||
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
return nil, caos_errs.ThrowInternal(err, "EMAIL-skwi4", "could not create smtp client")
|
||||
}
|
||||
return client, err
|
||||
}
|
||||
|
||||
func (smtpConfig SMTP) getSMPTClientWithStartTls(host string) (*smtp.Client, error) {
|
||||
client, err := smtpConfig.getSMPTClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := client.StartTLS(&tls.Config{
|
||||
ServerName: host,
|
||||
}); err != nil {
|
||||
return nil, caos_errs.ThrowInternal(err, "EMAIL-guvsQ", "could not start tls")
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (smtpConfig SMTP) smtpAuth(client *smtp.Client, host string) error {
|
||||
if !smtpConfig.HasAuth() {
|
||||
return nil
|
||||
}
|
||||
// Auth
|
||||
auth := smtp.PlainAuth("", smtpConfig.User, smtpConfig.Password, host)
|
||||
err := client.Auth(auth)
|
||||
logging.Log("EMAIL-s9kfs").WithField("smtp user", smtpConfig.User).OnError(err).Debug("could not add smtp auth")
|
||||
return err
|
||||
}
|
18
internal/notification/channels/smtp/config.go
Normal file
18
internal/notification/channels/smtp/config.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package smtp
|
||||
|
||||
type EmailConfig struct {
|
||||
SMTP SMTP
|
||||
Tls bool
|
||||
From string
|
||||
FromName string
|
||||
}
|
||||
|
||||
type SMTP struct {
|
||||
Host string
|
||||
User string
|
||||
Password string
|
||||
}
|
||||
|
||||
func (smtp *SMTP) HasAuth() bool {
|
||||
return smtp.User != "" && smtp.Password != ""
|
||||
}
|
28
internal/notification/channels/twilio/channel.go
Normal file
28
internal/notification/channels/twilio/channel.go
Normal 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
|
||||
})
|
||||
}
|
7
internal/notification/channels/twilio/config.go
Normal file
7
internal/notification/channels/twilio/config.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package twilio
|
||||
|
||||
type TwilioConfig struct {
|
||||
SID string
|
||||
Token string
|
||||
From string
|
||||
}
|
Reference in New Issue
Block a user