diff --git a/Makefile b/Makefile index 7096f1c80..f751e5270 100644 --- a/Makefile +++ b/Makefile @@ -143,6 +143,8 @@ mocks: $(GOMOCK) rm -f */*/*_mock_test.go # See https://github.com/golang/mock for details + $(MOCKGEN) -package service -source crm/service/notification.go -destination crm/service/notification_mock_test.go + $(MOCKGEN) -package service -source sam/service/attachment.go -destination sam/service/attachment_mock_test.go $(MOCKGEN) -package service -source sam/service/channel.go -destination sam/service/channel_mock_test.go $(MOCKGEN) -package service -source sam/service/message.go -destination sam/service/message_mock_test.go diff --git a/api/crm/spec.json b/api/crm/spec.json index 6bdf80f41..85acb69a8 100644 --- a/api/crm/spec.json +++ b/api/crm/spec.json @@ -751,5 +751,37 @@ } } ] + }, + { + "title": "Notifications", + "description": "CRM Notifications", + "package": "crm", + "entrypoint": "notification", + "path": "/notification", + "authentication": [], + "struct": [ + { + "imports": [ + "sqlxTypes github.com/jmoiron/sqlx/types" + ] + } + ], + "apis": [ + { + "name": "email/send", + "method": "POST", + "title": "Send email from the CRM", + "path": "/email", + "parameters": { + "post": [ + { "name": "to", "type": "[]string", "required": true, "title": "Email addresses or Crust user IDs" }, + { "name": "cc", "type": "[]string", "required": false, "title": "Email addresses or Crust user IDs" }, + { "name": "replyTo", "type": "string", "required": false, "title": "Crust user ID or email address in reply-to field" }, + { "name": "subject ", "type": "string", "required": false, "title": "Email subject" }, + { "name": "content", "type": "sqlxTypes.JSONText", "required": true, "title": "Message content" } + ] + } + } + ] } ] diff --git a/api/crm/spec/notification.json b/api/crm/spec/notification.json new file mode 100644 index 000000000..d25ee339b --- /dev/null +++ b/api/crm/spec/notification.json @@ -0,0 +1,59 @@ +{ + "Title": "Notifications", + "Description": "CRM Notifications", + "Package": "crm", + "Interface": "Notification", + "Struct": [ + { + "imports": [ + "sqlxTypes github.com/jmoiron/sqlx/types" + ] + } + ], + "Parameters": null, + "Protocol": "", + "Authentication": [], + "Path": "/notification", + "APIs": [ + { + "Name": "email/send", + "Method": "POST", + "Title": "Send email from the CRM", + "Path": "/email", + "Parameters": { + "post": [ + { + "name": "to", + "required": true, + "title": "Email addresses or Crust user IDs", + "type": "[]string" + }, + { + "name": "cc", + "required": false, + "title": "Email addresses or Crust user IDs", + "type": "[]string" + }, + { + "name": "replyTo", + "required": false, + "title": "Crust user ID or email address in reply-to field", + "type": "string" + }, + { + "name": "subject ", + "required": false, + "title": "Email subject", + "type": "string" + }, + { + "name": "content", + "required": true, + "title": "Message content", + "type": "sqlxTypes.JSONText" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/crm/rest/handlers/notification.go b/crm/rest/handlers/notification.go new file mode 100644 index 000000000..362143ae4 --- /dev/null +++ b/crm/rest/handlers/notification.go @@ -0,0 +1,57 @@ +package handlers + +/* + Hello! This file is auto-generated from `docs/src/spec.json`. + + For development: + In order to update the generated files, edit this file under the location, + add your struct fields, imports, API definitions and whatever you want, and: + + 1. run [spec](https://github.com/titpetric/spec) in the same folder, + 2. run `./_gen.php` in this folder. + + You may edit `notification.go`, `notification.util.go` or `notification_test.go` to + implement your API calls, helper functions and tests. The file `notification.go` + is only generated the first time, and will not be overwritten if it exists. +*/ + +import ( + "context" + "github.com/go-chi/chi" + "net/http" + + "github.com/titpetric/factory/resputil" + + "github.com/crusttech/crust/crm/rest/request" +) + +// Internal API interface +type NotificationAPI interface { + EmailSend(context.Context, *request.NotificationEmailSend) (interface{}, error) +} + +// HTTP API interface +type Notification struct { + EmailSend func(http.ResponseWriter, *http.Request) +} + +func NewNotification(nh NotificationAPI) *Notification { + return &Notification{ + EmailSend: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewNotificationEmailSend() + resputil.JSON(w, params.Fill(r), func() (interface{}, error) { + return nh.EmailSend(r.Context(), params) + }) + }, + } +} + +func (nh *Notification) MountRoutes(r chi.Router, middlewares ...func(http.Handler) http.Handler) { + r.Group(func(r chi.Router) { + r.Use(middlewares...) + r.Route("/notification", func(r chi.Router) { + r.Post("/email", nh.EmailSend) + }) + }) +} diff --git a/crm/rest/notification.go b/crm/rest/notification.go new file mode 100644 index 000000000..7583b7a5e --- /dev/null +++ b/crm/rest/notification.go @@ -0,0 +1,66 @@ +package rest + +import ( + "context" + + "github.com/crusttech/crust/crm/rest/request" + "github.com/crusttech/crust/crm/service" + "github.com/crusttech/crust/internal/mail" + + "github.com/pkg/errors" +) + +var _ = errors.Wrap + +type ( + Notification struct { + notification service.NotificationService + } + + contentPayload struct { + Plain string `json:"plain"` + Html string `json:"html"` + } +) + +func (Notification) New() *Notification { + return &Notification{ + notification: service.DefaultNotification, + } +} + +// EmailSend assembles Email Message and pushes message to notification service +func (ctrl *Notification) EmailSend(ctx context.Context, r *request.NotificationEmailSend) (interface{}, error) { + ntf := ctrl.notification.With(ctx) + + msg := mail.New() + if err := ntf.AttachEmailRecipients(msg, "To", r.To...); err != nil { + return false, err + } + + if err := ntf.AttachEmailRecipients(msg, "Cc", r.Cc...); err != nil { + return false, err + } + + var cp = contentPayload{} + if err := r.Content.Unmarshal(&cp); err != nil { + return false, errors.Wrap(err, "Could not unmarshal content") + } else { + if len(cp.Html) > 0 { + msg.SetBody("text/html", cp.Html) + + } + + if len(cp.Plain) > 0 { + msg.SetBody("text/plain", cp.Plain) + } + } + + msg.SetHeader("Subject", r.Subject) + + if err := ctrl.notification.With(ctx).SendEmail(msg); err != nil { + return false, err + } else { + return true, nil + } +} diff --git a/crm/rest/request/notification.go b/crm/rest/request/notification.go new file mode 100644 index 000000000..a8eb8787d --- /dev/null +++ b/crm/rest/request/notification.go @@ -0,0 +1,92 @@ +package request + +/* + Hello! This file is auto-generated from `docs/src/spec.json`. + + For development: + In order to update the generated files, edit this file under the location, + add your struct fields, imports, API definitions and whatever you want, and: + + 1. run [spec](https://github.com/titpetric/spec) in the same folder, + 2. run `./_gen.php` in this folder. + + You may edit `notification.go`, `notification.util.go` or `notification_test.go` to + implement your API calls, helper functions and tests. The file `notification.go` + is only generated the first time, and will not be overwritten if it exists. +*/ + +import ( + "encoding/json" + "io" + "mime/multipart" + "net/http" + "strings" + + "github.com/go-chi/chi" + "github.com/pkg/errors" + + sqlxTypes "github.com/jmoiron/sqlx/types" +) + +var _ = chi.URLParam +var _ = multipart.FileHeader{} + +// Notification email/send request parameters +type NotificationEmailSend struct { + To []string + Cc []string + ReplyTo string + Subject string + Content sqlxTypes.JSONText +} + +func NewNotificationEmailSend() *NotificationEmailSend { + return &NotificationEmailSend{} +} + +func (n *NotificationEmailSend) Fill(r *http.Request) (err error) { + if strings.ToLower(r.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(r.Body).Decode(n) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + if err = r.ParseForm(); err != nil { + return err + } + + get := map[string]string{} + post := map[string]string{} + urlQuery := r.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := r.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + if val, ok := post["replyTo"]; ok { + + n.ReplyTo = val + } + if val, ok := post["subject "]; ok { + + n.Subject = val + } + if val, ok := post["content"]; ok { + + if n.Content, err = parseJSONTextWithErr(val); err != nil { + return err + } + } + + return err +} + +var _ RequestFiller = NewNotificationEmailSend() diff --git a/crm/rest/router.go b/crm/rest/router.go index e8e705b04..caeebce47 100644 --- a/crm/rest/router.go +++ b/crm/rest/router.go @@ -9,10 +9,11 @@ import ( func MountRoutes(jwtAuth auth.TokenEncoder) func(chi.Router) { var ( - module = Module{}.New() - page = Page{}.New() - chart = Chart{}.New() - trigger = Trigger{}.New() + module = Module{}.New() + page = Page{}.New() + chart = Chart{}.New() + trigger = Trigger{}.New() + notification = Notification{}.New() ) // Initialize handers & controllers. @@ -25,6 +26,7 @@ func MountRoutes(jwtAuth auth.TokenEncoder) func(chi.Router) { handlers.NewModule(module).MountRoutes(r) handlers.NewChart(chart).MountRoutes(r) handlers.NewTrigger(trigger).MountRoutes(r) + handlers.NewNotification(notification).MountRoutes(r) }) } } diff --git a/crm/service/notification.go b/crm/service/notification.go new file mode 100644 index 000000000..1535b0b78 --- /dev/null +++ b/crm/service/notification.go @@ -0,0 +1,113 @@ +package service + +import ( + "context" + "strconv" + "strings" + + "github.com/pkg/errors" + gomail "gopkg.in/mail.v2" + + "github.com/crusttech/crust/internal/mail" + systemService "github.com/crusttech/crust/system/service" + systemTypes "github.com/crusttech/crust/system/types" +) + +type ( + notification struct { + ctx context.Context + + userSvc notificationUserService + } + + NotificationService interface { + With(ctx context.Context) NotificationService + SendEmail(message *gomail.Message) error + AttachEmailRecipients(message *gomail.Message, field string, recipients ...string) error + } + + notificationUserService interface { + With(ctx context.Context) systemService.UserService + FindByID(userID uint64) (*systemTypes.User, error) + } +) + +func Notification() NotificationService { + return (¬ification{ + userSvc: systemService.DefaultUser, + }).With(context.Background()) +} + +func (s *notification) With(ctx context.Context) NotificationService { + return ¬ification{ + ctx: ctx, + + userSvc: s.userSvc.With(ctx), + } +} + +func (s *notification) SendEmail(message *gomail.Message) error { + return mail.Send(message) +} + +// AttachEmailRecipients validates, resolves, formats andd attaches set of recipients to message +// +// Supports 3 input formats: +// - +// - +// - +// Last one is then translated into valid email + name (when/if possible) +func (s *notification) AttachEmailRecipients(message *gomail.Message, field string, recipients ...string) (err error) { + var ( + email string + name string + ) + + if len(recipients) == 0 { + return + } + + if recipients, err = s.expandUserRefs(s.userSvc.With(s.ctx), recipients); err != nil { + return + } + + for r, rcpt := range recipients { + name, email = "", "" + rcpt = strings.TrimSpace(rcpt) + + // First, get userID off the table + if spaceAt := strings.Index(rcpt, " "); spaceAt > -1 { + email, name = rcpt[:spaceAt], strings.TrimSpace(rcpt[spaceAt+1:]) + } else { + email = rcpt + } + + // Validate email here + if !mail.IsValidAddress(email) { + return errors.New("Invalid recipient email format") + } + + recipients[r] = message.FormatAddress(email, name) + } + + message.SetHeader(field, recipients...) + return +} + +// Expands references to users (strings as numeric uint64) +// +// This func is extracted to make testing/mocking mocking +func (s *notification) expandUserRefs(usrLookup notificationUserService, recipients []string) ([]string, error) { + for r, rcpt := range recipients { + // First, get userID off the table + if userID, _ := strconv.ParseUint(rcpt, 10, 64); userID > 0 { + if user, err := usrLookup.FindByID(userID); err != nil { + return nil, errors.Wrapf(err, "invalid recipient %s", userID) + } else { + recipients[r] = user.Email + " " + user.Name + } + } + } + + return recipients, nil +} diff --git a/crm/service/notification_mock_test.go b/crm/service/notification_mock_test.go new file mode 100644 index 000000000..dc9caa6f1 --- /dev/null +++ b/crm/service/notification_mock_test.go @@ -0,0 +1,126 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: crm/service/notification.go + +// Package service is a generated GoMock package. +package service + +import ( + context "context" + service0 "github.com/crusttech/crust/system/service" + types "github.com/crusttech/crust/system/types" + gomock "github.com/golang/mock/gomock" + mail_v2 "gopkg.in/mail.v2" + reflect "reflect" +) + +// MockNotificationService is a mock of NotificationService interface +type MockNotificationService struct { + ctrl *gomock.Controller + recorder *MockNotificationServiceMockRecorder +} + +// MockNotificationServiceMockRecorder is the mock recorder for MockNotificationService +type MockNotificationServiceMockRecorder struct { + mock *MockNotificationService +} + +// NewMockNotificationService creates a new mock instance +func NewMockNotificationService(ctrl *gomock.Controller) *MockNotificationService { + mock := &MockNotificationService{ctrl: ctrl} + mock.recorder = &MockNotificationServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockNotificationService) EXPECT() *MockNotificationServiceMockRecorder { + return m.recorder +} + +// With mocks base method +func (m *MockNotificationService) With(ctx context.Context) NotificationService { + ret := m.ctrl.Call(m, "With", ctx) + ret0, _ := ret[0].(NotificationService) + return ret0 +} + +// With indicates an expected call of With +func (mr *MockNotificationServiceMockRecorder) With(ctx interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "With", reflect.TypeOf((*MockNotificationService)(nil).With), ctx) +} + +// SendEmail mocks base method +func (m *MockNotificationService) SendEmail(message *mail_v2.Message) error { + ret := m.ctrl.Call(m, "SendEmail", message) + ret0, _ := ret[0].(error) + return ret0 +} + +// SendEmail indicates an expected call of SendEmail +func (mr *MockNotificationServiceMockRecorder) SendEmail(message interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendEmail", reflect.TypeOf((*MockNotificationService)(nil).SendEmail), message) +} + +// AttachEmailRecipients mocks base method +func (m *MockNotificationService) AttachEmailRecipients(message *mail_v2.Message, field string, recipients ...string) error { + varargs := []interface{}{message, field} + for _, a := range recipients { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "AttachEmailRecipients", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// AttachEmailRecipients indicates an expected call of AttachEmailRecipients +func (mr *MockNotificationServiceMockRecorder) AttachEmailRecipients(message, field interface{}, recipients ...interface{}) *gomock.Call { + varargs := append([]interface{}{message, field}, recipients...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AttachEmailRecipients", reflect.TypeOf((*MockNotificationService)(nil).AttachEmailRecipients), varargs...) +} + +// MocknotificationUserService is a mock of notificationUserService interface +type MocknotificationUserService struct { + ctrl *gomock.Controller + recorder *MocknotificationUserServiceMockRecorder +} + +// MocknotificationUserServiceMockRecorder is the mock recorder for MocknotificationUserService +type MocknotificationUserServiceMockRecorder struct { + mock *MocknotificationUserService +} + +// NewMocknotificationUserService creates a new mock instance +func NewMocknotificationUserService(ctrl *gomock.Controller) *MocknotificationUserService { + mock := &MocknotificationUserService{ctrl: ctrl} + mock.recorder = &MocknotificationUserServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MocknotificationUserService) EXPECT() *MocknotificationUserServiceMockRecorder { + return m.recorder +} + +// With mocks base method +func (m *MocknotificationUserService) With(ctx context.Context) service0.UserService { + ret := m.ctrl.Call(m, "With", ctx) + ret0, _ := ret[0].(service0.UserService) + return ret0 +} + +// With indicates an expected call of With +func (mr *MocknotificationUserServiceMockRecorder) With(ctx interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "With", reflect.TypeOf((*MocknotificationUserService)(nil).With), ctx) +} + +// FindByID mocks base method +func (m *MocknotificationUserService) FindByID(userID uint64) (*types.User, error) { + ret := m.ctrl.Call(m, "FindByID", userID) + ret0, _ := ret[0].(*types.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindByID indicates an expected call of FindByID +func (mr *MocknotificationUserServiceMockRecorder) FindByID(userID interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByID", reflect.TypeOf((*MocknotificationUserService)(nil).FindByID), userID) +} diff --git a/crm/service/notification_test.go b/crm/service/notification_test.go new file mode 100644 index 000000000..7b17d1e4d --- /dev/null +++ b/crm/service/notification_test.go @@ -0,0 +1,56 @@ +package service + +import ( + "testing" + + "github.com/golang/mock/gomock" + gomail "gopkg.in/mail.v2" + + "github.com/crusttech/crust/internal/test" + "github.com/crusttech/crust/system/types" +) + +func TestUserRefExpanding(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + ntf := ¬ification{} + + // msg := &gomail.Message{} + usr := &types.User{ID: 72932592256548967, Email: "user@mock.ed", Name: "Mocked User"} + + usrSvc := NewMocknotificationUserService(mockCtrl) + usrSvc.EXPECT().FindByID(usr.ID).Times(1).Return(usr, nil) + ntf.userSvc = usrSvc + + input := []string{"sample@domain.tld", "sample@domain.tld Name", "72932592256548967"} + rcpts, err := ntf.expandUserRefs(usrSvc, input) + test.ErrNil(t, err, "expandUserRefs returned an error: %v") + test.Assert(t, len(rcpts) == len(input), "Expecting %d headers, got %d", len(input), len(rcpts)) + test.Assert(t, rcpts[2] == usr.Email+" "+usr.Name, "Expecting %d headers, got %d", len(input), len(rcpts)) +} + +func TestAttachEmailRecipients(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + ntf := ¬ification{} + + usrSvc := NewMocknotificationUserService(mockCtrl) + usrSvc.EXPECT().With(gomock.Any()) + ntf.userSvc = usrSvc + + msg := gomail.NewMessage() + + input := []string{"sample@domain.tld", "sample2@domain.tld First Name"} + err := ntf.AttachEmailRecipients(msg, "To", input...) + to := msg.GetHeader("To") + + test.ErrNil(t, err, "AttachEmailRecipients returned an error: %v") + + test.Assert(t, len(input) == len(to), "Expecting %d headers, got %d", len(input), len(to)) + + test.Assert(t, to[0] == "sample@domain.tld", "Expecting address to match, got %v", to[0]) + + test.Assert(t, to[1] == "\"First Name\" ", "Expecting address to match, got %v", to[1]) +} diff --git a/crm/service/service.go b/crm/service/service.go index bb9c9c46e..4137b9a60 100644 --- a/crm/service/service.go +++ b/crm/service/service.go @@ -5,12 +5,13 @@ import ( ) var ( - o sync.Once - DefaultRecord RecordService - DefaultModule ModuleService - DefaultTrigger TriggerService - DefaultChart ChartService - DefaultPage PageService + o sync.Once + DefaultRecord RecordService + DefaultModule ModuleService + DefaultTrigger TriggerService + DefaultChart ChartService + DefaultPage PageService + DefaultNotification NotificationService ) func Init() { @@ -20,5 +21,6 @@ func Init() { DefaultTrigger = Trigger() DefaultPage = Page() DefaultChart = Chart() + DefaultNotification = Notification() }) } diff --git a/docs/crm/README.md b/docs/crm/README.md index bffa12895..3aeef69e0 100644 --- a/docs/crm/README.md +++ b/docs/crm/README.md @@ -253,6 +253,31 @@ CRM module definitions +# Notifications + +CRM Notifications + +## Send email from the CRM + +#### Method + +| URI | Protocol | Method | Authentication | +| --- | -------- | ------ | -------------- | +| `/notification/email` | HTTP/S | POST | | + +#### Request parameters + +| Parameter | Type | Method | Description | Default | Required? | +| --------- | ---- | ------ | ----------- | ------- | --------- | +| to | []string | POST | Email addresses or Crust user IDs | N/A | YES | +| cc | []string | POST | Email addresses or Crust user IDs | N/A | NO | +| replyTo | string | POST | Crust user ID or email address in reply-to field | N/A | NO | +| subject | string | POST | Email subject | N/A | NO | +| content | sqlxTypes.JSONText | POST | Message content | N/A | YES | + + + + # Pages CRM module pages diff --git a/internal/mail/mail_mock_test.go b/internal/mail/mail_mock_test.go index 7a89cb19f..144de809b 100644 --- a/internal/mail/mail_mock_test.go +++ b/internal/mail/mail_mock_test.go @@ -1,14 +1,13 @@ // Code generated by MockGen. DO NOT EDIT. // Source: internal/mail/mail.go -// Package service is a generated GoMock package. +// Package mail is a generated GoMock package. package mail import ( - reflect "reflect" - gomock "github.com/golang/mock/gomock" mail_v2 "gopkg.in/mail.v2" + reflect "reflect" ) // MockDialer is a mock of Dialer interface