Skip to content

Add external transactional endpoint (#1108) #1754

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
263 changes: 188 additions & 75 deletions cmd/tx.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"fmt"
"io"
"net/http"
"net/textproto"
"strings"

"github.com/knadh/listmonk/internal/manager"
Expand All @@ -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
}

Expand All @@ -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 (
Expand Down Expand Up @@ -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
}
}
Expand All @@ -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,
Expand Down Expand Up @@ -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
}
46 changes: 45 additions & 1 deletion docs/docs/content/apis/transactional.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

______________________________________________________________________

Expand Down Expand Up @@ -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": "[email protected]",
"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.
Expand Down
2 changes: 1 addition & 1 deletion docs/site/layouts/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ <h2>Transactional mails</h2>
<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>
Expand Down
Loading