This commit is contained in:
Facecube 2024-09-10 14:46:54 +02:00 committed by GitHub
commit ab1a4ed425
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 365 additions and 87 deletions

View file

@ -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)

263
cmd/tx.go
View file

@ -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
}

View file

@ -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.

View file

@ -110,7 +110,7 @@ sh -c "$(curl -fsSL https://raw.githubusercontent.com/knadh/listmonk/master/inst
<img class="box" src="static/images/tx.png" alt="Screenshot of transactional API" />
</div>
<p>
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.
</p>
</section>

View file

@ -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.

View file

@ -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
}