feat: notifications (#109)

* implement notification providers

* email provider

* notification handler

* notify users

* implement code sent on user eventstore

* send email implementation

* send init code

* handle codes

* fix project member handler

* add some logs for debug

* send emails

* text changes

* send sms

* notification process

* send password code

* format phone number

* test format phone

* remove fmts

* remove unused code

* rename files

* add mocks

* merge master

* Update internal/notification/providers/email/message.go

Co-authored-by: Silvan <silvan.reusser@gmail.com>

* Update internal/notification/repository/eventsourcing/handler/notification.go

Co-authored-by: Silvan <silvan.reusser@gmail.com>

* Update internal/notification/repository/eventsourcing/handler/notification.go

Co-authored-by: Silvan <silvan.reusser@gmail.com>

* Update internal/notification/providers/email/provider.go

Co-authored-by: Silvan <silvan.reusser@gmail.com>

* requested changes of mr

* move locker to eventstore pkg

* Update internal/notification/providers/chat/message.go

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

* move locker to eventstore pkg

* linebreak

* Update internal/notification/providers/email/provider.go

Co-authored-by: Silvan <silvan.reusser@gmail.com>

* Update internal/notification/repository/eventsourcing/handler/notification.go

Co-authored-by: Silvan <silvan.reusser@gmail.com>

* Update internal/notification/repository/eventsourcing/handler/notification.go

Co-authored-by: Silvan <silvan.reusser@gmail.com>

Co-authored-by: Silvan <silvan.reusser@gmail.com>
Co-authored-by: Livio Amstutz <livio.a@gmail.com>
This commit is contained in:
Fabi
2020-05-20 14:28:08 +02:00
committed by GitHub
parent c365a98cc8
commit e318139b37
67 changed files with 3278 additions and 119 deletions

View File

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

View File

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

View File

@@ -0,0 +1,75 @@
package chat
import (
"bytes"
"encoding/json"
caos_errs "github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/notification/providers"
"net/http"
"net/url"
"unicode/utf8"
)
type Chat struct {
URL *url.URL
SplitCount int
}
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
}
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 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)
if err != nil {
return caos_errs.ThrowInternal(err, "PROVI-s8uie", "Could not unmarshal content")
}
_, err = http.Post(chat.URL.String(), "application/json; charset=UTF-8", bytes.NewReader(req))
if err != nil {
return caos_errs.ThrowInternal(err, "PROVI-si93s", "unable to send message")
}
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
}

View File

@@ -0,0 +1,18 @@
package email
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 != ""
}

View File

@@ -0,0 +1,47 @@
package email
import (
"fmt"
"regexp"
"strings"
)
var (
isHTMLRgx = regexp.MustCompile(`.*<html.*>.*`)
lineBreak = "\r\n"
)
type EmailMessage struct {
Recipients []string
BCC []string
CC []string
SenderEmail string
Subject string
Content string
}
func (msg *EmailMessage) GetContent() string {
headers := make(map[string]string)
headers["From"] = msg.SenderEmail
headers["To"] = strings.Join(msg.Recipients, ", ")
headers["Cc"] = strings.Join(msg.CC, ", ")
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: " + msg.Subject + lineBreak
message += subject + mime + lineBreak + msg.Content
return message
}
func isHTML(input string) bool {
return isHTMLRgx.MatchString(input)
}

View File

@@ -0,0 +1,123 @@
package email
import (
"crypto/tls"
"github.com/caos/logging"
caos_errs "github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/notification/providers"
"net"
"net/smtp"
)
type Email struct {
smtpClient *smtp.Client
}
func InitEmailProvider(config EmailConfig) (*Email, error) {
client, err := config.SMTP.connectToSMTP(config.Tls)
if err != nil {
return nil, err
}
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 {
defer email.smtpClient.Close()
emailMsg, ok := message.(*EmailMessage)
if !ok {
return caos_errs.ThrowInternal(nil, "EMAIL-s8JLs", "message is not EmailMessage")
}
// 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 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) 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
}

View File

@@ -0,0 +1,4 @@
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

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

View File

@@ -0,0 +1,47 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/caos/zitadel/internal/notification/providers (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))
}

View File

@@ -0,0 +1,61 @@
// 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

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

View File

@@ -0,0 +1,7 @@
package twilio
type TwilioConfig struct {
SID string
Token string
From string
}

View File

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

View File

@@ -0,0 +1,39 @@
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
}