From 4e8307315204e3f7f02feed02de5aee86d81b965 Mon Sep 17 00:00:00 2001 From: fandrae Date: Sat, 24 Feb 2024 18:46:16 +0100 Subject: [PATCH] Add external transactional endpoint (#1108) (cherry picked from commit e0473db7861b63d328be506542c340f2e95b6a03) --- cmd/handlers.go | 1 + cmd/tx.go | 263 +++++++++++++++++------- docs/docs/content/apis/transactional.md | 46 ++++- docs/site/layouts/index.html | 2 +- docs/swagger/collections.yaml | 23 +++ models/models.go | 117 ++++++++++- 6 files changed, 365 insertions(+), 87 deletions(-) diff --git a/cmd/handlers.go b/cmd/handlers.go index 51b7fb77..2c921ad2 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -163,6 +163,7 @@ func initHTTPHandlers(e *echo.Echo, app *App) { g.DELETE("/api/maintenance/subscriptions/unconfirmed", handleGCSubscriptions) g.POST("/api/tx", handleSendTxMessage) + g.POST("/api/tx/external", handleSendExternalTxMessage) g.GET("/api/events", handleEventStream) diff --git a/cmd/tx.go b/cmd/tx.go index 743fcba2..f7bcd473 100644 --- a/cmd/tx.go +++ b/cmd/tx.go @@ -5,7 +5,6 @@ import ( "fmt" "io" "net/http" - "net/textproto" "strings" "github.com/knadh/listmonk/internal/manager" @@ -20,49 +19,8 @@ func handleSendTxMessage(c echo.Context) error { m models.TxMessage ) - // If it's a multipart form, there may be file attachments. - if strings.HasPrefix(c.Request().Header.Get("Content-Type"), "multipart/form-data") { - form, err := c.MultipartForm() - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("globals.messages.invalidFields", "name", err.Error())) - } - - data, ok := form.Value["data"] - if !ok || len(data) != 1 { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("globals.messages.invalidFields", "name", "data")) - } - - // Parse the JSON data. - if err := json.Unmarshal([]byte(data[0]), &m); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("globals.messages.invalidFields", "name", fmt.Sprintf("data: %s", err.Error()))) - } - - // Attach files. - for _, f := range form.File["file"] { - file, err := f.Open() - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.invalidFields", "name", fmt.Sprintf("file: %s", err.Error()))) - } - defer file.Close() - - b, err := io.ReadAll(file) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.invalidFields", "name", fmt.Sprintf("file: %s", err.Error()))) - } - - m.Attachments = append(m.Attachments, models.Attachment{ - Name: f.Filename, - Header: manager.MakeAttachmentHeader(f.Filename, "base64", f.Header.Get("Content-Type")), - Content: b, - }) - } - - } else if err := c.Bind(&m); err != nil { + m, err := parseTxMessage(c, app) + if err != nil { return err } @@ -73,11 +31,10 @@ func handleSendTxMessage(c echo.Context) error { m = r } - // Get the cached tx template. - tpl, err := app.manager.GetTpl(m.TemplateID) + // Get the template + tpl, err := getTemplate(app, m.TemplateID) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("globals.messages.notFound", "name", fmt.Sprintf("template %d", m.TemplateID))) + return err } var ( @@ -121,34 +78,9 @@ func handleSendTxMessage(c echo.Context) error { } // Prepare the final message. - msg := models.Message{} - msg.Subscriber = sub - msg.To = []string{sub.Email} - msg.From = m.FromEmail - msg.Subject = m.Subject - msg.ContentType = m.ContentType - msg.Messenger = m.Messenger - msg.Body = m.Body - for _, a := range m.Attachments { - msg.Attachments = append(msg.Attachments, models.Attachment{ - Name: a.Name, - Header: a.Header, - Content: a.Content, - }) - } + msg := models.CreateMailMessage(sub, m) - // Optional headers. - if len(m.Headers) != 0 { - msg.Headers = make(textproto.MIMEHeader, len(m.Headers)) - for _, set := range m.Headers { - for hdr, val := range set { - msg.Headers.Add(hdr, val) - } - } - } - - if err := app.manager.PushMessage(msg); err != nil { - app.log.Printf("error sending message (%s): %v", msg.Subject, err) + if err := sendEmail(app, msg); err != nil { return err } } @@ -160,6 +92,27 @@ func handleSendTxMessage(c echo.Context) error { return c.JSON(http.StatusOK, okResp{true}) } +func parseTxMessage(c echo.Context, app *App) (models.TxMessage, error) { + m := models.TxMessage{} + // If it's a multipart form, there may be file attachments. + if strings.HasPrefix(c.Request().Header.Get("Content-Type"), "multipart/form-data") { + if data, attachments, err := parseMultiPartMessageDetails(c, app); err != nil { + return models.TxMessage{}, err + } else { + // Parse the JSON data. + if err := json.Unmarshal([]byte(data[0]), &m); err != nil { + return models.TxMessage{}, echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.invalidFields", "name", fmt.Sprintf("data: %s", err.Error()))) + } + + m.Attachments = append(m.Attachments, attachments...) + } + } else if err := c.Bind(&m); err != nil { + return models.TxMessage{}, err + } + return m, nil +} + func validateTxMessage(m models.TxMessage, app *App) (models.TxMessage, error) { if len(m.SubscriberEmails) > 0 && m.SubscriberEmail != "" { return m, echo.NewHTTPError(http.StatusBadRequest, @@ -205,3 +158,163 @@ func validateTxMessage(m models.TxMessage, app *App) (models.TxMessage, error) { return m, nil } + +// handleSendExternalTxMessage handles the sending of a transactional message to an external recipient. +func handleSendExternalTxMessage(c echo.Context) error { + var ( + app = c.Get("app").(*App) + m models.ExternalTxMessage + ) + + m, err := parseExternalTxMessage(c, app) + if err != nil { + return err + } + + // Validate input. + if r, err := validateExternalTxMessage(m, app); err != nil { + return err + } else { + m = r + } + + // Get the template + tpl, err := getTemplate(app, m.TemplateID) + if err != nil { + return err + } + + txMessage := m.MapToTxMessage() + notFound := []string{} + for n := 0; n < len(txMessage.SubscriberEmails); n++ { + // Render the message. + if err := txMessage.Render(models.Subscriber{}, tpl); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.errorFetching", "name")) + } + + // Prepare the final message. + msg := models.CreateMailMessage(models.Subscriber{Email: txMessage.SubscriberEmails[n]}, txMessage) + + if err := sendEmail(app, msg); err != nil { + return err + } + } + + if len(notFound) > 0 { + return echo.NewHTTPError(http.StatusBadRequest, strings.Join(notFound, "; ")) + } + + return c.JSON(http.StatusOK, okResp{true}) +} + +func parseExternalTxMessage(c echo.Context, app *App) (models.ExternalTxMessage, error) { + m := models.ExternalTxMessage{} + // If it's a multipart form, there may be file attachments. + if strings.HasPrefix(c.Request().Header.Get("Content-Type"), "multipart/form-data") { + if data, attachments, err := parseMultiPartMessageDetails(c, app); err != nil { + return models.ExternalTxMessage{}, err + } else { + // Parse the JSON data. + if err := json.Unmarshal([]byte(data[0]), &m); err != nil { + return models.ExternalTxMessage{}, echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.invalidFields", "name", fmt.Sprintf("data: %s", err.Error()))) + } + + m.Attachments = append(m.Attachments, attachments...) + } + } else if err := c.Bind(&m); err != nil { + return models.ExternalTxMessage{}, err + } + return m, nil +} + +func validateExternalTxMessage(m models.ExternalTxMessage, app *App) (models.ExternalTxMessage, error) { + if len(m.RecipientEmails) > 0 && m.RecipientEmail != "" { + return m, echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.invalidFields", "name", "do not send `subscriber_email`")) + } + + if m.RecipientEmail != "" { + m.RecipientEmails = append(m.RecipientEmails, m.RecipientEmail) + } + + for n, email := range m.RecipientEmails { + if m.RecipientEmail != "" { + em, err := app.importer.SanitizeEmail(email) + if err != nil { + return m, echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + m.RecipientEmails[n] = em + } + } + + if m.FromEmail == "" { + m.FromEmail = app.constants.FromEmail + } + + if m.Messenger == "" { + m.Messenger = emailMsgr + } else if !app.manager.HasMessenger(m.Messenger) { + return m, echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("campaigns.fieldInvalidMessenger", "name", m.Messenger)) + } + + return m, nil +} + +func parseMultiPartMessageDetails(c echo.Context, app *App) ([]string, []models.Attachment, error) { + form, err := c.MultipartForm() + if err != nil { + return nil, nil, echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.invalidFields", "name", err.Error())) + } + + data, ok := form.Value["data"] + if !ok || len(data) != 1 { + return nil, nil, echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.invalidFields", "name", "data")) + } + + attachments := []models.Attachment{} + // Attach files. + for _, f := range form.File["file"] { + file, err := f.Open() + if err != nil { + return nil, nil, echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts("globals.messages.invalidFields", "name", fmt.Sprintf("file: %s", err.Error()))) + } + defer file.Close() + + b, err := io.ReadAll(file) + if err != nil { + return nil, nil, echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts("globals.messages.invalidFields", "name", fmt.Sprintf("file: %s", err.Error()))) + } + + attachments = append(attachments, models.Attachment{ + Name: f.Filename, + Header: manager.MakeAttachmentHeader(f.Filename, "base64", f.Header.Get("Content-Type")), + Content: b, + }) + } + + return data, attachments, nil +} + +func getTemplate(app *App, templateId int) (*models.Template, error) { + // Get the cached tx template. + tpl, err := app.manager.GetTpl(templateId) + if err != nil { + return nil, echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.notFound", "name", fmt.Sprintf("template %d", templateId))) + } + return tpl, nil +} + +func sendEmail(app *App, msg models.Message) error { + if err := app.manager.PushMessage(msg); err != nil { + app.log.Printf("error sending message (%s): %v", msg.Subject, err) + return err + } + return nil +} diff --git a/docs/docs/content/apis/transactional.md b/docs/docs/content/apis/transactional.md index 130fa756..0e63c332 100644 --- a/docs/docs/content/apis/transactional.md +++ b/docs/docs/content/apis/transactional.md @@ -2,7 +2,8 @@ | Method | Endpoint | Description | |:-------|:---------|:-------------------------------| -| POST | /api/tx | Send transactional messages | +| POST | /api/tx | Send transactional messages to subscribers | +| POST | /api/tx/external | Send transactional messages to anyone | ______________________________________________________________________ @@ -50,6 +51,49 @@ EOF ______________________________________________________________________ +#### POST /api/tx/external + +Allows sending transactional messages to one or more external recipients via a preconfigured transactional template. +The recipients don't have to be subscribers. +This means that the template will not have access to subscriber metadata. + +##### Parameters + +| Name | Type | Required | Description | +|:------------------|:----------|:---------|:---------------------------------------------------------------------------| +| recipient_email | string | | Email of the recipient. | +| recipient_emails | string\[\] | | Multiple recipient emails as alternative to `recipient_email`. | +| template_id | number | Yes | ID of the transactional template to be used for the message. | +| from_email | string | | Optional sender email. | +| data | JSON | | Optional nested JSON map. Available in the template as `{{ .Tx.Data.* }}`. | +| headers | JSON\[\] | | Optional array of email headers. | +| messenger | string | | Messenger to send the message. Default is `email`. | +| content_type | string | | Email format options include `html`, `markdown`, and `plain`. | + +##### Example + +```shell +curl -u "username:password" "http://localhost:9000/api/tx/external" -X POST \ + -H 'Content-Type: application/json; charset=utf-8' \ + --data-binary @- << EOF + { + "recipient_email": "user@test.com", + "template_id": 2, + "data": {"order_id": "1234", "date": "2022-07-30", "items": [1, 2, 3]}, + "content_type": "html" + } +EOF +``` + +##### Example response + +```json +{ + "data": true +} +``` +______________________________________________________________________ + #### File Attachments To include file attachments in a transactional message, use the `multipart/form-data` Content-Type. Use `data` param for the parameters described above as a JSON object. Include any number of attachments via the `file` param. diff --git a/docs/site/layouts/index.html b/docs/site/layouts/index.html index 934d86a8..3e9001e9 100644 --- a/docs/site/layouts/index.html +++ b/docs/site/layouts/index.html @@ -108,7 +108,7 @@ sh -c "$(curl -fsSL https://raw.githubusercontent.com/knadh/listmonk/master/inst Screenshot of transactional API

- Simple API to send arbitrary transactional messages to subscribers + Simple API to send arbitrary transactional messages to subscribers and external recipients using pre-defined templates. Send messages as e-mail, SMS, Whatsapp messages or any medium via Messenger interfaces.

diff --git a/docs/swagger/collections.yaml b/docs/swagger/collections.yaml index d68df248..618dac93 100644 --- a/docs/swagger/collections.yaml +++ b/docs/swagger/collections.yaml @@ -1846,6 +1846,29 @@ paths: data: type: boolean + /tx/external: + post: + tags: + - Transactional + description: send message to anyone + operationId: transactWithAnyone + requestBody: + description: email message to recipient + content: + application/json: + schema: + $ref: "#/components/schemas/TransactionalMessage" + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + "/maintenance/subscribers/{type}": delete: description: garbage collects (deletes) orphaned or blocklisted subscribers. diff --git a/models/models.go b/models/models.go index 1d67d436..c7c25a03 100644 --- a/models/models.go +++ b/models/models.go @@ -409,6 +409,55 @@ type TxMessage struct { SubjectTpl *txttpl.Template `json:"-"` } +// ExternalTxMessage represents an e-mail campaign to an external recipient. +type ExternalTxMessage struct { + RecipientEmails []string `json:"recipient_emails"` + + // Deprecated. + RecipientEmail string `json:"recipient_email"` + + TemplateID int `json:"template_id"` + Data map[string]interface{} `json:"data"` + FromEmail string `json:"from_email"` + Headers Headers `json:"headers"` + ContentType string `json:"content_type"` + Messenger string `json:"messenger"` + + // File attachments added from multi-part form data. + Attachments []Attachment `json:"-"` + + Subject string `json:"-"` + Body []byte `json:"-"` + Tpl *template.Template `json:"-"` + SubjectTpl *txttpl.Template `json:"-"` +} + +func (m ExternalTxMessage) MapToTxMessage() TxMessage { + txMessage := TxMessage{} + txMessage.SubscriberEmails = m.RecipientEmails + txMessage.SubscriberIDs = []int{} + + // Deprecated. + txMessage.SubscriberEmail = m.RecipientEmail + txMessage.SubscriberID = 0 + + txMessage.TemplateID = m.TemplateID + txMessage.Data = m.Data + txMessage.FromEmail = m.FromEmail + txMessage.Headers = m.Headers + txMessage.ContentType = m.ContentType + txMessage.Messenger = m.Messenger + + // File attachments added from multi-part form data. + txMessage.Attachments = m.Attachments + + txMessage.Subject = m.Subject + txMessage.Body = m.Body + txMessage.Tpl = m.Tpl + txMessage.SubjectTpl = m.SubjectTpl + return txMessage +} + // markdown is a global instance of Markdown parser and renderer. var markdown = goldmark.New( goldmark.WithParserOptions( @@ -651,27 +700,45 @@ func (m *TxMessage) Render(sub Subscriber, tpl *Template) error { Tx *TxMessage }{sub, m} + message, err := renderMessage(data, tpl) + if err != nil { + return err + } + m.Body = message + + subject, err := renderSubject(data, tpl) + if err != nil { + return err + } + m.Subject = subject + + return nil +} + +func renderMessage(data any, tpl *Template) ([]byte, error) { // Render the body. b := bytes.Buffer{} if err := tpl.Tpl.ExecuteTemplate(&b, BaseTpl, data); err != nil { - return err + return nil, err } - m.Body = make([]byte, b.Len()) - copy(m.Body, b.Bytes()) - b.Reset() + message := make([]byte, b.Len()) + copy(message, b.Bytes()) + return message, nil +} +func renderSubject(data any, tpl *Template) (string, error) { // If the subject is also a template, render that. + b := bytes.Buffer{} + subject := "" if tpl.SubjectTpl != nil { if err := tpl.SubjectTpl.ExecuteTemplate(&b, BaseTpl, data); err != nil { - return err + return "", err } - m.Subject = b.String() - b.Reset() + subject = b.String() } else { - m.Subject = tpl.Subject + subject = tpl.Subject } - - return nil + return subject, nil } // FirstName splits the name by spaces and returns the first chunk @@ -738,3 +805,33 @@ func (h Headers) Value() (driver.Value, error) { return "[]", nil } + +func CreateMailMessage(sub Subscriber, m TxMessage) Message { + msg := Message{} + msg.Subscriber = sub + msg.To = []string{sub.Email} + msg.From = m.FromEmail + msg.Subject = m.Subject + msg.ContentType = m.ContentType + msg.Messenger = m.Messenger + msg.Body = m.Body + for _, a := range m.Attachments { + msg.Attachments = append(msg.Attachments, Attachment{ + Name: a.Name, + Header: a.Header, + Content: a.Content, + }) + } + + // Optional headers. + if len(m.Headers) != 0 { + msg.Headers = make(textproto.MIMEHeader, len(m.Headers)) + for _, set := range m.Headers { + for hdr, val := range set { + msg.Headers.Add(hdr, val) + } + } + } + + return msg +}