listmonk/cmd/bounce.go
2021-08-22 15:43:35 +05:30

256 lines
7.2 KiB
Go

package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"strconv"
"strings"
"time"
"github.com/knadh/listmonk/internal/subimporter"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo"
"github.com/lib/pq"
)
type bouncesWrap struct {
Results []models.Bounce `json:"results"`
Total int `json:"total"`
PerPage int `json:"per_page"`
Page int `json:"page"`
}
// handleGetBounces handles retrieval of bounce records.
func handleGetBounces(c echo.Context) error {
var (
app = c.Get("app").(*App)
pg = getPagination(c.QueryParams(), 50)
out bouncesWrap
id, _ = strconv.Atoi(c.Param("id"))
campID, _ = strconv.Atoi(c.QueryParam("campaign_id"))
source = c.FormValue("source")
orderBy = c.FormValue("order_by")
order = c.FormValue("order")
)
// Fetch one list.
single := false
if id > 0 {
single = true
}
// Sort params.
if !strSliceContains(orderBy, bounceQuerySortFields) {
orderBy = "created_at"
}
if order != sortAsc && order != sortDesc {
order = sortDesc
}
stmt := fmt.Sprintf(app.queries.QueryBounces, orderBy, order)
if err := db.Select(&out.Results, stmt, id, campID, 0, source, pg.Offset, pg.Limit); err != nil {
app.log.Printf("error fetching bounces: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.bounce}", "error", pqErrMsg(err)))
}
if len(out.Results) == 0 {
out.Results = []models.Bounce{}
return c.JSON(http.StatusOK, okResp{out})
}
if single {
return c.JSON(http.StatusOK, okResp{out.Results[0]})
}
// Meta.
out.Total = out.Results[0].Total
out.Page = pg.Page
out.PerPage = pg.PerPage
return c.JSON(http.StatusOK, okResp{out})
}
// handleGetSubscriberBounces retrieves a subscriber's bounce records.
func handleGetSubscriberBounces(c echo.Context) error {
var (
app = c.Get("app").(*App)
subID = c.Param("id")
)
id, _ := strconv.ParseInt(subID, 10, 64)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
out := []models.Bounce{}
stmt := fmt.Sprintf(app.queries.QueryBounces, "created_at", "ASC")
if err := db.Select(&out, stmt, 0, 0, subID, "", 0, 1000); err != nil {
app.log.Printf("error fetching bounces: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.bounce}", "error", pqErrMsg(err)))
}
return c.JSON(http.StatusOK, okResp{out})
}
// handleDeleteBounces handles bounce deletion, either a single one (ID in the URI), or a list.
func handleDeleteBounces(c echo.Context) error {
var (
app = c.Get("app").(*App)
pID = c.Param("id")
all, _ = strconv.ParseBool(c.QueryParam("all"))
IDs = pq.Int64Array{}
)
// Is it an /:id call?
if pID != "" {
id, _ := strconv.ParseInt(pID, 10, 64)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
IDs = append(IDs, id)
} else if !all {
// Multiple IDs.
i, err := parseStringIDs(c.Request().URL.Query()["id"])
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.invalidID", "error", err.Error()))
}
if len(i) == 0 {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.invalidID"))
}
IDs = i
}
if _, err := app.queries.DeleteBounces.Exec(IDs); err != nil {
app.log.Printf("error deleting bounces: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorDeleting",
"name", "{globals.terms.bounce}", "error", pqErrMsg(err)))
}
return c.JSON(http.StatusOK, okResp{true})
}
// handleBounceWebhook renders the HTML preview of a template.
func handleBounceWebhook(c echo.Context) error {
var (
app = c.Get("app").(*App)
service = c.Param("service")
bounces []models.Bounce
)
// Read the request body instead of using using c.Bind() to read to save the entire raw request as meta.
rawReq, err := ioutil.ReadAll(c.Request().Body)
if err != nil {
app.log.Printf("error reading ses notification body: %v", err)
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.internalError"))
}
switch true {
// Native internal webhook.
case service == "":
var b models.Bounce
if err := json.Unmarshal(rawReq, &b); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidData"))
}
if err := validateBounceFields(b, app); err != nil {
return err
}
b.Email = strings.ToLower(b.Email)
if len(b.Meta) == 0 {
b.Meta = json.RawMessage("{}")
}
if b.CreatedAt.Year() == 0 {
b.CreatedAt = time.Now()
}
bounces = append(bounces, b)
// Amazon SES.
case service == "ses" && app.constants.BounceSESEnabled:
switch c.Request().Header.Get("X-Amz-Sns-Message-Type") {
// SNS webhook registration confirmation. Only after these are processed will the endpoint
// start getting bounce notifications.
case "SubscriptionConfirmation", "UnsubscribeConfirmation":
if err := app.bounce.SES.ProcessSubscription(rawReq); err != nil {
app.log.Printf("error processing SNS (SES) subscription: %v", err)
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
}
break
// Bounce notification.
case "Notification":
b, err := app.bounce.SES.ProcessBounce(rawReq)
if err != nil {
app.log.Printf("error processing SES notification: %v", err)
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
}
bounces = append(bounces, b)
default:
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
}
// SendGrid.
case service == "sendgrid" && app.constants.BounceSendgridEnabled:
var (
sig = c.Request().Header.Get("X-Twilio-Email-Event-Webhook-Signature")
ts = c.Request().Header.Get("X-Twilio-Email-Event-Webhook-Timestamp")
)
// Sendgrid sends multiple bounces.
bs, err := app.bounce.Sendgrid.ProcessBounce(sig, ts, rawReq)
if err != nil {
app.log.Printf("error processing sendgrid notification: %v", err)
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
}
bounces = append(bounces, bs...)
default:
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("bounces.unknownService"))
}
// Record bounces if any.
for _, b := range bounces {
if err := app.bounce.Record(b); err != nil {
app.log.Printf("error recording bounce: %v", err)
}
}
return c.JSON(http.StatusOK, okResp{true})
}
func validateBounceFields(b models.Bounce, app *App) error {
if b.Email == "" && b.SubscriberUUID == "" {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
}
if b.Type != models.BounceTypeHard && b.Type != models.BounceTypeSoft {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
}
if b.Email != "" && !subimporter.IsEmail(b.Email) {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidEmail"))
}
if b.SubscriberUUID != "" && !reUUID.MatchString(b.SubscriberUUID) {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidUUID"))
}
return nil
}