3
0

Notification endpoint (for sending emails from the CRM)

This commit is contained in:
Denis Arh
2019-01-13 19:07:38 +01:00
parent 39b3d0a6bb
commit 7c36966e68
13 changed files with 644 additions and 13 deletions
+2
View File
@@ -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
+32
View File
@@ -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" }
]
}
}
]
}
]
+59
View File
@@ -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"
}
]
}
}
]
}
+57
View File
@@ -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)
})
})
}
+66
View File
@@ -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
}
}
+92
View File
@@ -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()
+6 -4
View File
@@ -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)
})
}
}
+113
View File
@@ -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 (&notification{
userSvc: systemService.DefaultUser,
}).With(context.Background())
}
func (s *notification) With(ctx context.Context) NotificationService {
return &notification{
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:
// - <valid email>
// - <valid email><space><name...>
// - <userID>
// 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
}
+126
View File
@@ -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)
}
+56
View File
@@ -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 := &notification{}
// 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 := &notification{}
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\" <sample2@domain.tld>", "Expecting address to match, got %v", to[1])
}
+8 -6
View File
@@ -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()
})
}
+25
View File
@@ -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
+2 -3
View File
@@ -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