bounce.go 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. package main
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "io/ioutil"
  6. "net/http"
  7. "strconv"
  8. "time"
  9. "github.com/knadh/listmonk/models"
  10. "github.com/labstack/echo"
  11. "github.com/lib/pq"
  12. )
  13. type bouncesWrap struct {
  14. Results []models.Bounce `json:"results"`
  15. Total int `json:"total"`
  16. PerPage int `json:"per_page"`
  17. Page int `json:"page"`
  18. }
  19. // handleGetBounces handles retrieval of bounce records.
  20. func handleGetBounces(c echo.Context) error {
  21. var (
  22. app = c.Get("app").(*App)
  23. pg = getPagination(c.QueryParams(), 50)
  24. out bouncesWrap
  25. id, _ = strconv.Atoi(c.Param("id"))
  26. campID, _ = strconv.Atoi(c.QueryParam("campaign_id"))
  27. source = c.FormValue("source")
  28. orderBy = c.FormValue("order_by")
  29. order = c.FormValue("order")
  30. )
  31. // Fetch one list.
  32. single := false
  33. if id > 0 {
  34. single = true
  35. }
  36. // Sort params.
  37. if !strSliceContains(orderBy, bounceQuerySortFields) {
  38. orderBy = "created_at"
  39. }
  40. if order != sortAsc && order != sortDesc {
  41. order = sortDesc
  42. }
  43. stmt := fmt.Sprintf(app.queries.QueryBounces, orderBy, order)
  44. if err := db.Select(&out.Results, stmt, id, campID, 0, source, pg.Offset, pg.Limit); err != nil {
  45. app.log.Printf("error fetching bounces: %v", err)
  46. return echo.NewHTTPError(http.StatusInternalServerError,
  47. app.i18n.Ts("globals.messages.errorFetching",
  48. "name", "{globals.terms.bounce}", "error", pqErrMsg(err)))
  49. }
  50. if len(out.Results) == 0 {
  51. out.Results = []models.Bounce{}
  52. return c.JSON(http.StatusOK, okResp{out})
  53. }
  54. if single {
  55. return c.JSON(http.StatusOK, okResp{out.Results[0]})
  56. }
  57. // Meta.
  58. out.Total = out.Results[0].Total
  59. out.Page = pg.Page
  60. out.PerPage = pg.PerPage
  61. return c.JSON(http.StatusOK, okResp{out})
  62. }
  63. // handleGetSubscriberBounces retrieves a subscriber's bounce records.
  64. func handleGetSubscriberBounces(c echo.Context) error {
  65. var (
  66. app = c.Get("app").(*App)
  67. subID = c.Param("id")
  68. )
  69. id, _ := strconv.ParseInt(subID, 10, 64)
  70. if id < 1 {
  71. return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
  72. }
  73. out := []models.Bounce{}
  74. stmt := fmt.Sprintf(app.queries.QueryBounces, "created_at", "ASC")
  75. if err := db.Select(&out, stmt, 0, 0, subID, "", 0, 1000); err != nil {
  76. app.log.Printf("error fetching bounces: %v", err)
  77. return echo.NewHTTPError(http.StatusInternalServerError,
  78. app.i18n.Ts("globals.messages.errorFetching",
  79. "name", "{globals.terms.bounce}", "error", pqErrMsg(err)))
  80. }
  81. return c.JSON(http.StatusOK, okResp{out})
  82. }
  83. // handleDeleteBounces handles bounce deletion, either a single one (ID in the URI), or a list.
  84. func handleDeleteBounces(c echo.Context) error {
  85. var (
  86. app = c.Get("app").(*App)
  87. pID = c.Param("id")
  88. all, _ = strconv.ParseBool(c.QueryParam("all"))
  89. IDs = pq.Int64Array{}
  90. )
  91. // Is it an /:id call?
  92. if pID != "" {
  93. id, _ := strconv.ParseInt(pID, 10, 64)
  94. if id < 1 {
  95. return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
  96. }
  97. IDs = append(IDs, id)
  98. } else if !all {
  99. // Multiple IDs.
  100. i, err := parseStringIDs(c.Request().URL.Query()["id"])
  101. if err != nil {
  102. return echo.NewHTTPError(http.StatusBadRequest,
  103. app.i18n.Ts("globals.messages.invalidID", "error", err.Error()))
  104. }
  105. if len(i) == 0 {
  106. return echo.NewHTTPError(http.StatusBadRequest,
  107. app.i18n.Ts("globals.messages.invalidID"))
  108. }
  109. IDs = i
  110. }
  111. if _, err := app.queries.DeleteBounces.Exec(IDs); err != nil {
  112. app.log.Printf("error deleting bounces: %v", err)
  113. return echo.NewHTTPError(http.StatusInternalServerError,
  114. app.i18n.Ts("globals.messages.errorDeleting",
  115. "name", "{globals.terms.bounce}", "error", pqErrMsg(err)))
  116. }
  117. return c.JSON(http.StatusOK, okResp{true})
  118. }
  119. // handleBounceWebhook renders the HTML preview of a template.
  120. func handleBounceWebhook(c echo.Context) error {
  121. var (
  122. app = c.Get("app").(*App)
  123. service = c.Param("service")
  124. bounces []models.Bounce
  125. )
  126. // Read the request body instead of using using c.Bind() to read to save the entire raw request as meta.
  127. rawReq, err := ioutil.ReadAll(c.Request().Body)
  128. if err != nil {
  129. app.log.Printf("error reading ses notification body: %v", err)
  130. return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.internalError"))
  131. }
  132. switch true {
  133. // Native internal webhook.
  134. case service == "":
  135. var b models.Bounce
  136. if err := json.Unmarshal(rawReq, &b); err != nil {
  137. return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidData"))
  138. }
  139. if bv, err := validateBounceFields(b, app); err != nil {
  140. return err
  141. } else {
  142. b = bv
  143. }
  144. if len(b.Meta) == 0 {
  145. b.Meta = json.RawMessage("{}")
  146. }
  147. if b.CreatedAt.Year() == 0 {
  148. b.CreatedAt = time.Now()
  149. }
  150. bounces = append(bounces, b)
  151. // Amazon SES.
  152. case service == "ses" && app.constants.BounceSESEnabled:
  153. switch c.Request().Header.Get("X-Amz-Sns-Message-Type") {
  154. // SNS webhook registration confirmation. Only after these are processed will the endpoint
  155. // start getting bounce notifications.
  156. case "SubscriptionConfirmation", "UnsubscribeConfirmation":
  157. if err := app.bounce.SES.ProcessSubscription(rawReq); err != nil {
  158. app.log.Printf("error processing SNS (SES) subscription: %v", err)
  159. return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
  160. }
  161. break
  162. // Bounce notification.
  163. case "Notification":
  164. b, err := app.bounce.SES.ProcessBounce(rawReq)
  165. if err != nil {
  166. app.log.Printf("error processing SES notification: %v", err)
  167. return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
  168. }
  169. bounces = append(bounces, b)
  170. default:
  171. return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
  172. }
  173. // SendGrid.
  174. case service == "sendgrid" && app.constants.BounceSendgridEnabled:
  175. var (
  176. sig = c.Request().Header.Get("X-Twilio-Email-Event-Webhook-Signature")
  177. ts = c.Request().Header.Get("X-Twilio-Email-Event-Webhook-Timestamp")
  178. )
  179. // Sendgrid sends multiple bounces.
  180. bs, err := app.bounce.Sendgrid.ProcessBounce(sig, ts, rawReq)
  181. if err != nil {
  182. app.log.Printf("error processing sendgrid notification: %v", err)
  183. return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
  184. }
  185. bounces = append(bounces, bs...)
  186. default:
  187. return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("bounces.unknownService"))
  188. }
  189. // Record bounces if any.
  190. for _, b := range bounces {
  191. if err := app.bounce.Record(b); err != nil {
  192. app.log.Printf("error recording bounce: %v", err)
  193. }
  194. }
  195. return c.JSON(http.StatusOK, okResp{true})
  196. }
  197. func validateBounceFields(b models.Bounce, app *App) (models.Bounce, error) {
  198. if b.Email == "" && b.SubscriberUUID == "" {
  199. return b, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
  200. }
  201. if b.SubscriberUUID != "" && !reUUID.MatchString(b.SubscriberUUID) {
  202. return b, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidUUID"))
  203. }
  204. if b.Email != "" {
  205. em, err := app.importer.SanitizeEmail(b.Email)
  206. if err != nil {
  207. return b, echo.NewHTTPError(http.StatusBadRequest, err.Error())
  208. }
  209. b.Email = em
  210. }
  211. if b.Type != models.BounceTypeHard && b.Type != models.BounceTypeSoft {
  212. return b, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
  213. }
  214. return b, nil
  215. }