campaigns.go 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796
  1. package main
  2. import (
  3. "bytes"
  4. "database/sql"
  5. "errors"
  6. "fmt"
  7. "html/template"
  8. "net/http"
  9. "net/url"
  10. "regexp"
  11. "strconv"
  12. "strings"
  13. "time"
  14. "github.com/gofrs/uuid"
  15. "github.com/jmoiron/sqlx"
  16. "github.com/knadh/listmonk/models"
  17. "github.com/labstack/echo"
  18. "github.com/lib/pq"
  19. null "gopkg.in/volatiletech/null.v6"
  20. )
  21. // campaignReq is a wrapper over the Campaign model for receiving
  22. // campaign creation and updation data from APIs.
  23. type campaignReq struct {
  24. models.Campaign
  25. // Indicates if the "send_at" date should be written or set to null.
  26. SendLater bool `db:"-" json:"send_later"`
  27. // This overrides Campaign.Lists to receive and
  28. // write a list of int IDs during creation and updation.
  29. // Campaign.Lists is JSONText for sending lists children
  30. // to the outside world.
  31. ListIDs pq.Int64Array `db:"-" json:"lists"`
  32. // This is only relevant to campaign test requests.
  33. SubscriberEmails pq.StringArray `json:"subscribers"`
  34. Type string `json:"type"`
  35. }
  36. // campaignContentReq wraps params coming from API requests for converting
  37. // campaign content formats.
  38. type campaignContentReq struct {
  39. models.Campaign
  40. From string `json:"from"`
  41. To string `json:"to"`
  42. }
  43. type campCountStats struct {
  44. CampaignID int `db:"campaign_id" json:"campaign_id"`
  45. Count int `db:"count" json:"count"`
  46. Timestamp time.Time `db:"timestamp" json:"timestamp"`
  47. }
  48. type campTopLinks struct {
  49. URL string `db:"url" json:"url"`
  50. Count int `db:"count" json:"count"`
  51. }
  52. type campaignStats struct {
  53. ID int `db:"id" json:"id"`
  54. Status string `db:"status" json:"status"`
  55. ToSend int `db:"to_send" json:"to_send"`
  56. Sent int `db:"sent" json:"sent"`
  57. Started null.Time `db:"started_at" json:"started_at"`
  58. UpdatedAt null.Time `db:"updated_at" json:"updated_at"`
  59. Rate float64 `json:"rate"`
  60. }
  61. type campsWrap struct {
  62. Results models.Campaigns `json:"results"`
  63. Query string `json:"query"`
  64. Total int `json:"total"`
  65. PerPage int `json:"per_page"`
  66. Page int `json:"page"`
  67. }
  68. var (
  69. regexFromAddress = regexp.MustCompile(`(.+?)\s<(.+?)@(.+?)>`)
  70. regexFullTextQuery = regexp.MustCompile(`\s+`)
  71. campaignQuerySortFields = []string{"name", "status", "created_at", "updated_at"}
  72. bounceQuerySortFields = []string{"email", "campaign_name", "source", "created_at"}
  73. )
  74. // handleGetCampaigns handles retrieval of campaigns.
  75. func handleGetCampaigns(c echo.Context) error {
  76. var (
  77. app = c.Get("app").(*App)
  78. pg = getPagination(c.QueryParams(), 20)
  79. out campsWrap
  80. id, _ = strconv.Atoi(c.Param("id"))
  81. status = c.QueryParams()["status"]
  82. query = strings.TrimSpace(c.FormValue("query"))
  83. orderBy = c.FormValue("order_by")
  84. order = c.FormValue("order")
  85. noBody, _ = strconv.ParseBool(c.QueryParam("no_body"))
  86. )
  87. // Fetch one list.
  88. single := false
  89. if id > 0 {
  90. single = true
  91. }
  92. queryStr, stmt := makeCampaignQuery(query, orderBy, order, app.queries.QueryCampaigns)
  93. // Unsafe to ignore scanning fields not present in models.Campaigns.
  94. if err := db.Select(&out.Results, stmt, id, pq.StringArray(status), queryStr, pg.Offset, pg.Limit); err != nil {
  95. app.log.Printf("error fetching campaigns: %v", err)
  96. return echo.NewHTTPError(http.StatusInternalServerError,
  97. app.i18n.Ts("globals.messages.errorFetching",
  98. "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
  99. }
  100. if single && len(out.Results) == 0 {
  101. return echo.NewHTTPError(http.StatusBadRequest,
  102. app.i18n.Ts("campaigns.notFound", "name", "{globals.terms.campaign}"))
  103. }
  104. if len(out.Results) == 0 {
  105. out.Results = []models.Campaign{}
  106. return c.JSON(http.StatusOK, okResp{out})
  107. }
  108. for i := 0; i < len(out.Results); i++ {
  109. // Replace null tags.
  110. if out.Results[i].Tags == nil {
  111. out.Results[i].Tags = make(pq.StringArray, 0)
  112. }
  113. if noBody {
  114. out.Results[i].Body = ""
  115. }
  116. }
  117. // Lazy load stats.
  118. if err := out.Results.LoadStats(app.queries.GetCampaignStats); err != nil {
  119. app.log.Printf("error fetching campaign stats: %v", err)
  120. return echo.NewHTTPError(http.StatusInternalServerError,
  121. app.i18n.Ts("globals.messages.errorFetching",
  122. "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
  123. }
  124. if single {
  125. return c.JSON(http.StatusOK, okResp{out.Results[0]})
  126. }
  127. // Meta.
  128. out.Total = out.Results[0].Total
  129. out.Page = pg.Page
  130. out.PerPage = pg.PerPage
  131. return c.JSON(http.StatusOK, okResp{out})
  132. }
  133. // handlePreviewCampaign renders the HTML preview of a campaign body.
  134. func handlePreviewCampaign(c echo.Context) error {
  135. var (
  136. app = c.Get("app").(*App)
  137. id, _ = strconv.Atoi(c.Param("id"))
  138. )
  139. if id < 1 {
  140. return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
  141. }
  142. var camp models.Campaign
  143. err := app.queries.GetCampaignForPreview.Get(&camp, id)
  144. if err != nil {
  145. if err == sql.ErrNoRows {
  146. return echo.NewHTTPError(http.StatusBadRequest,
  147. app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
  148. }
  149. app.log.Printf("error fetching campaign: %v", err)
  150. return echo.NewHTTPError(http.StatusInternalServerError,
  151. app.i18n.Ts("globals.messages.errorFetching",
  152. "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
  153. }
  154. // There's a body in the request to preview instead of the body in the DB.
  155. if c.Request().Method == http.MethodPost {
  156. camp.ContentType = c.FormValue("content_type")
  157. camp.Body = c.FormValue("body")
  158. }
  159. // Use a dummy campaign ID to prevent views and clicks from {{ TrackView }}
  160. // and {{ TrackLink }} being registered on preview.
  161. camp.UUID = dummySubscriber.UUID
  162. if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil {
  163. app.log.Printf("error compiling template: %v", err)
  164. return echo.NewHTTPError(http.StatusBadRequest,
  165. app.i18n.Ts("templates.errorCompiling", "error", err.Error()))
  166. }
  167. // Render the message body.
  168. msg, err := app.manager.NewCampaignMessage(&camp, dummySubscriber)
  169. if err != nil {
  170. app.log.Printf("error rendering message: %v", err)
  171. return echo.NewHTTPError(http.StatusBadRequest,
  172. app.i18n.Ts("templates.errorRendering", "error", err.Error()))
  173. }
  174. return c.HTML(http.StatusOK, string(msg.Body()))
  175. }
  176. // handleCampaignContent handles campaign content (body) format conversions.
  177. func handleCampaignContent(c echo.Context) error {
  178. var (
  179. app = c.Get("app").(*App)
  180. id, _ = strconv.Atoi(c.Param("id"))
  181. )
  182. if id < 1 {
  183. return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
  184. }
  185. var camp campaignContentReq
  186. if err := c.Bind(&camp); err != nil {
  187. return err
  188. }
  189. out, err := camp.ConvertContent(camp.From, camp.To)
  190. if err != nil {
  191. return echo.NewHTTPError(http.StatusBadRequest, err.Error())
  192. }
  193. return c.JSON(http.StatusOK, okResp{out})
  194. }
  195. // handleCreateCampaign handles campaign creation.
  196. // Newly created campaigns are always drafts.
  197. func handleCreateCampaign(c echo.Context) error {
  198. var (
  199. app = c.Get("app").(*App)
  200. o campaignReq
  201. )
  202. if err := c.Bind(&o); err != nil {
  203. return err
  204. }
  205. // If the campaign's 'opt-in', prepare a default message.
  206. if o.Type == models.CampaignTypeOptin {
  207. op, err := makeOptinCampaignMessage(o, app)
  208. if err != nil {
  209. return err
  210. }
  211. o = op
  212. }
  213. // Validate.
  214. if c, err := validateCampaignFields(o, app); err != nil {
  215. return echo.NewHTTPError(http.StatusBadRequest, err.Error())
  216. } else {
  217. o = c
  218. }
  219. uu, err := uuid.NewV4()
  220. if err != nil {
  221. app.log.Printf("error generating UUID: %v", err)
  222. return echo.NewHTTPError(http.StatusInternalServerError,
  223. app.i18n.Ts("globals.messages.errorUUID", "error", err.Error()))
  224. }
  225. // Insert and read ID.
  226. var newID int
  227. if err := app.queries.CreateCampaign.Get(&newID,
  228. uu,
  229. o.Type,
  230. o.Name,
  231. o.Subject,
  232. o.FromEmail,
  233. o.Body,
  234. o.AltBody,
  235. o.ContentType,
  236. o.SendAt,
  237. pq.StringArray(normalizeTags(o.Tags)),
  238. o.Messenger,
  239. o.TemplateID,
  240. o.ListIDs,
  241. ); err != nil {
  242. if err == sql.ErrNoRows {
  243. return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.noSubs"))
  244. }
  245. app.log.Printf("error creating campaign: %v", err)
  246. return echo.NewHTTPError(http.StatusInternalServerError,
  247. app.i18n.Ts("globals.messages.errorCreating",
  248. "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
  249. }
  250. // Hand over to the GET handler to return the last insertion.
  251. return handleGetCampaigns(copyEchoCtx(c, map[string]string{
  252. "id": fmt.Sprintf("%d", newID),
  253. }))
  254. }
  255. // handleUpdateCampaign handles campaign modification.
  256. // Campaigns that are done cannot be modified.
  257. func handleUpdateCampaign(c echo.Context) error {
  258. var (
  259. app = c.Get("app").(*App)
  260. id, _ = strconv.Atoi(c.Param("id"))
  261. )
  262. if id < 1 {
  263. return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
  264. }
  265. var cm models.Campaign
  266. if err := app.queries.GetCampaign.Get(&cm, id, nil); err != nil {
  267. if err == sql.ErrNoRows {
  268. return echo.NewHTTPError(http.StatusInternalServerError,
  269. app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
  270. }
  271. app.log.Printf("error fetching campaign: %v", err)
  272. return echo.NewHTTPError(http.StatusInternalServerError,
  273. app.i18n.Ts("globals.messages.errorFetching",
  274. "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
  275. }
  276. if isCampaignalMutable(cm.Status) {
  277. return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.cantUpdate"))
  278. }
  279. // Read the incoming params into the existing campaign fields from the DB.
  280. // This allows updating of values that have been sent where as fields
  281. // that are not in the request retain the old values.
  282. o := campaignReq{Campaign: cm}
  283. if err := c.Bind(&o); err != nil {
  284. return err
  285. }
  286. if c, err := validateCampaignFields(o, app); err != nil {
  287. return echo.NewHTTPError(http.StatusBadRequest, err.Error())
  288. } else {
  289. o = c
  290. }
  291. _, err := app.queries.UpdateCampaign.Exec(cm.ID,
  292. o.Name,
  293. o.Subject,
  294. o.FromEmail,
  295. o.Body,
  296. o.AltBody,
  297. o.ContentType,
  298. o.SendAt,
  299. o.SendLater,
  300. pq.StringArray(normalizeTags(o.Tags)),
  301. o.Messenger,
  302. o.TemplateID,
  303. o.ListIDs)
  304. if err != nil {
  305. app.log.Printf("error updating campaign: %v", err)
  306. return echo.NewHTTPError(http.StatusInternalServerError,
  307. app.i18n.Ts("globals.messages.errorUpdating",
  308. "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
  309. }
  310. return handleGetCampaigns(c)
  311. }
  312. // handleUpdateCampaignStatus handles campaign status modification.
  313. func handleUpdateCampaignStatus(c echo.Context) error {
  314. var (
  315. app = c.Get("app").(*App)
  316. id, _ = strconv.Atoi(c.Param("id"))
  317. )
  318. if id < 1 {
  319. return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
  320. }
  321. var cm models.Campaign
  322. if err := app.queries.GetCampaign.Get(&cm, id, nil); err != nil {
  323. if err == sql.ErrNoRows {
  324. return echo.NewHTTPError(http.StatusBadRequest,
  325. app.i18n.Ts("globals.message.notFound", "name", "{globals.terms.campaign}"))
  326. }
  327. app.log.Printf("error fetching campaign: %v", err)
  328. return echo.NewHTTPError(http.StatusInternalServerError,
  329. app.i18n.Ts("globals.messages.errorFetching",
  330. "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
  331. }
  332. // Incoming params.
  333. var o campaignReq
  334. if err := c.Bind(&o); err != nil {
  335. return err
  336. }
  337. errMsg := ""
  338. switch o.Status {
  339. case models.CampaignStatusDraft:
  340. if cm.Status != models.CampaignStatusScheduled {
  341. errMsg = app.i18n.T("campaigns.onlyScheduledAsDraft")
  342. }
  343. case models.CampaignStatusScheduled:
  344. if cm.Status != models.CampaignStatusDraft {
  345. errMsg = app.i18n.T("campaigns.onlyDraftAsScheduled")
  346. }
  347. if !cm.SendAt.Valid {
  348. errMsg = app.i18n.T("campaigns.needsSendAt")
  349. }
  350. case models.CampaignStatusRunning:
  351. if cm.Status != models.CampaignStatusPaused && cm.Status != models.CampaignStatusDraft {
  352. errMsg = app.i18n.T("campaigns.onlyPausedDraft")
  353. }
  354. case models.CampaignStatusPaused:
  355. if cm.Status != models.CampaignStatusRunning {
  356. errMsg = app.i18n.T("campaigns.onlyActivePause")
  357. }
  358. case models.CampaignStatusCancelled:
  359. if cm.Status != models.CampaignStatusRunning && cm.Status != models.CampaignStatusPaused {
  360. errMsg = app.i18n.T("campaigns.onlyActiveCancel")
  361. }
  362. }
  363. if len(errMsg) > 0 {
  364. return echo.NewHTTPError(http.StatusBadRequest, errMsg)
  365. }
  366. res, err := app.queries.UpdateCampaignStatus.Exec(cm.ID, o.Status)
  367. if err != nil {
  368. app.log.Printf("error updating campaign status: %v", err)
  369. return echo.NewHTTPError(http.StatusInternalServerError,
  370. app.i18n.Ts("globals.messages.errorUpdating",
  371. "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
  372. }
  373. if n, _ := res.RowsAffected(); n == 0 {
  374. return echo.NewHTTPError(http.StatusBadRequest,
  375. app.i18n.Ts("globals.messages.notFound",
  376. "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
  377. }
  378. return handleGetCampaigns(c)
  379. }
  380. // handleDeleteCampaign handles campaign deletion.
  381. // Only scheduled campaigns that have not started yet can be deleted.
  382. func handleDeleteCampaign(c echo.Context) error {
  383. var (
  384. app = c.Get("app").(*App)
  385. id, _ = strconv.Atoi(c.Param("id"))
  386. )
  387. if id < 1 {
  388. return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
  389. }
  390. var cm models.Campaign
  391. if err := app.queries.GetCampaign.Get(&cm, id, nil); err != nil {
  392. if err == sql.ErrNoRows {
  393. return echo.NewHTTPError(http.StatusBadRequest,
  394. app.i18n.Ts("globals.messages.notFound",
  395. "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
  396. }
  397. app.log.Printf("error fetching campaign: %v", err)
  398. return echo.NewHTTPError(http.StatusInternalServerError,
  399. app.i18n.Ts("globals.messages.errorFetching",
  400. "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
  401. }
  402. if _, err := app.queries.DeleteCampaign.Exec(cm.ID); err != nil {
  403. app.log.Printf("error deleting campaign: %v", err)
  404. return echo.NewHTTPError(http.StatusInternalServerError,
  405. app.i18n.Ts("globals.messages.errorDeleting",
  406. "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
  407. }
  408. return c.JSON(http.StatusOK, okResp{true})
  409. }
  410. // handleGetRunningCampaignStats returns stats of a given set of campaign IDs.
  411. func handleGetRunningCampaignStats(c echo.Context) error {
  412. var (
  413. app = c.Get("app").(*App)
  414. out []campaignStats
  415. )
  416. if err := app.queries.GetCampaignStatus.Select(&out, models.CampaignStatusRunning); err != nil {
  417. if err == sql.ErrNoRows {
  418. return c.JSON(http.StatusOK, okResp{[]struct{}{}})
  419. }
  420. app.log.Printf("error fetching campaign stats: %v", err)
  421. return echo.NewHTTPError(http.StatusInternalServerError,
  422. app.i18n.Ts("globals.messages.errorFetching",
  423. "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
  424. } else if len(out) == 0 {
  425. return c.JSON(http.StatusOK, okResp{[]struct{}{}})
  426. }
  427. // Compute rate.
  428. for i, c := range out {
  429. if c.Started.Valid && c.UpdatedAt.Valid {
  430. diff := c.UpdatedAt.Time.Sub(c.Started.Time).Minutes()
  431. if diff > 0 {
  432. var (
  433. sent = float64(c.Sent)
  434. rate = sent / diff
  435. )
  436. if rate > sent || rate > float64(c.ToSend) {
  437. rate = sent
  438. }
  439. out[i].Rate = rate
  440. }
  441. }
  442. }
  443. return c.JSON(http.StatusOK, okResp{out})
  444. }
  445. // handleTestCampaign handles the sending of a campaign message to
  446. // arbitrary subscribers for testing.
  447. func handleTestCampaign(c echo.Context) error {
  448. var (
  449. app = c.Get("app").(*App)
  450. campID, _ = strconv.Atoi(c.Param("id"))
  451. req campaignReq
  452. )
  453. if campID < 1 {
  454. return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.errorID"))
  455. }
  456. // Get and validate fields.
  457. if err := c.Bind(&req); err != nil {
  458. return err
  459. }
  460. // Validate.
  461. if c, err := validateCampaignFields(req, app); err != nil {
  462. return echo.NewHTTPError(http.StatusBadRequest, err.Error())
  463. } else {
  464. req = c
  465. }
  466. if len(req.SubscriberEmails) == 0 {
  467. return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.noSubsToTest"))
  468. }
  469. // Get the subscribers.
  470. for i := 0; i < len(req.SubscriberEmails); i++ {
  471. req.SubscriberEmails[i] = strings.ToLower(strings.TrimSpace(req.SubscriberEmails[i]))
  472. }
  473. var subs models.Subscribers
  474. if err := app.queries.GetSubscribersByEmails.Select(&subs, req.SubscriberEmails); err != nil {
  475. app.log.Printf("error fetching subscribers: %v", err)
  476. return echo.NewHTTPError(http.StatusInternalServerError,
  477. app.i18n.Ts("globals.messages.errorFetching",
  478. "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
  479. } else if len(subs) == 0 {
  480. return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.noKnownSubsToTest"))
  481. }
  482. // The campaign.
  483. var camp models.Campaign
  484. if err := app.queries.GetCampaignForPreview.Get(&camp, campID); err != nil {
  485. if err == sql.ErrNoRows {
  486. return echo.NewHTTPError(http.StatusBadRequest,
  487. app.i18n.Ts("globals.messages.notFound",
  488. "name", "{globals.terms.campaign}"))
  489. }
  490. app.log.Printf("error fetching campaign: %v", err)
  491. return echo.NewHTTPError(http.StatusInternalServerError,
  492. app.i18n.Ts("globals.messages.errorFetching",
  493. "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
  494. }
  495. // Override certain values from the DB with incoming values.
  496. camp.Name = req.Name
  497. camp.Subject = req.Subject
  498. camp.FromEmail = req.FromEmail
  499. camp.Body = req.Body
  500. camp.AltBody = req.AltBody
  501. camp.Messenger = req.Messenger
  502. camp.ContentType = req.ContentType
  503. camp.TemplateID = req.TemplateID
  504. // Send the test messages.
  505. for _, s := range subs {
  506. sub := s
  507. if err := sendTestMessage(sub, &camp, app); err != nil {
  508. app.log.Printf("error sending test message: %v", err)
  509. return echo.NewHTTPError(http.StatusInternalServerError,
  510. app.i18n.Ts("campaigns.errorSendTest", "error", err.Error()))
  511. }
  512. }
  513. return c.JSON(http.StatusOK, okResp{true})
  514. }
  515. // handleGetCampaignViewAnalytics retrieves view counts for a campaign.
  516. func handleGetCampaignViewAnalytics(c echo.Context) error {
  517. var (
  518. app = c.Get("app").(*App)
  519. typ = c.Param("type")
  520. from = c.QueryParams().Get("from")
  521. to = c.QueryParams().Get("to")
  522. )
  523. ids, err := parseStringIDs(c.Request().URL.Query()["id"])
  524. if err != nil {
  525. return echo.NewHTTPError(http.StatusBadRequest,
  526. app.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error()))
  527. }
  528. if len(ids) == 0 {
  529. return echo.NewHTTPError(http.StatusBadRequest,
  530. app.i18n.Ts("globals.messages.missingFields", "name", "`id`"))
  531. }
  532. // Pick campaign view counts or click counts.
  533. var stmt *sqlx.Stmt
  534. switch typ {
  535. case "views":
  536. stmt = app.queries.GetCampaignViewCounts
  537. case "clicks":
  538. stmt = app.queries.GetCampaignClickCounts
  539. case "bounces":
  540. stmt = app.queries.GetCampaignBounceCounts
  541. case "links":
  542. out := make([]campTopLinks, 0)
  543. if err := app.queries.GetCampaignLinkCounts.Select(&out, pq.Int64Array(ids), from, to); err != nil {
  544. app.log.Printf("error fetching campaign %s: %v", typ, err)
  545. return echo.NewHTTPError(http.StatusInternalServerError,
  546. app.i18n.Ts("globals.messages.errorFetching",
  547. "name", "{globals.terms.analytics}", "error", pqErrMsg(err)))
  548. }
  549. return c.JSON(http.StatusOK, okResp{out})
  550. default:
  551. return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
  552. }
  553. if !strHasLen(from, 10, 30) || !strHasLen(to, 10, 30) {
  554. return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("analytics.invalidDates"))
  555. }
  556. out := make([]campCountStats, 0)
  557. if err := stmt.Select(&out, pq.Int64Array(ids), from, to); err != nil {
  558. app.log.Printf("error fetching campaign %s: %v", typ, err)
  559. return echo.NewHTTPError(http.StatusInternalServerError,
  560. app.i18n.Ts("globals.messages.errorFetching",
  561. "name", "{globals.terms.analytics}", "error", pqErrMsg(err)))
  562. }
  563. return c.JSON(http.StatusOK, okResp{out})
  564. }
  565. // sendTestMessage takes a campaign and a subsriber and sends out a sample campaign message.
  566. func sendTestMessage(sub models.Subscriber, camp *models.Campaign, app *App) error {
  567. if err := camp.CompileTemplate(app.manager.TemplateFuncs(camp)); err != nil {
  568. app.log.Printf("error compiling template: %v", err)
  569. return echo.NewHTTPError(http.StatusInternalServerError,
  570. app.i18n.Ts("templates.errorCompiling", "error", err.Error()))
  571. }
  572. // Create a sample campaign message.
  573. msg, err := app.manager.NewCampaignMessage(camp, sub)
  574. if err != nil {
  575. app.log.Printf("error rendering message: %v", err)
  576. return echo.NewHTTPError(http.StatusNotFound,
  577. app.i18n.Ts("templates.errorRendering", "error", err.Error()))
  578. }
  579. return app.manager.PushCampaignMessage(msg)
  580. }
  581. // validateCampaignFields validates incoming campaign field values.
  582. func validateCampaignFields(c campaignReq, app *App) (campaignReq, error) {
  583. if c.FromEmail == "" {
  584. c.FromEmail = app.constants.FromEmail
  585. } else if !regexFromAddress.Match([]byte(c.FromEmail)) {
  586. if _, err := app.importer.SanitizeEmail(c.FromEmail); err != nil {
  587. return c, errors.New(app.i18n.T("campaigns.fieldInvalidFromEmail"))
  588. }
  589. }
  590. if !strHasLen(c.Name, 1, stdInputMaxLen) {
  591. return c, errors.New(app.i18n.T("campaigns.fieldInvalidName"))
  592. }
  593. if !strHasLen(c.Subject, 1, stdInputMaxLen) {
  594. return c, errors.New(app.i18n.T("campaigns.fieldInvalidSubject"))
  595. }
  596. // if !hasLen(c.Body, 1, bodyMaxLen) {
  597. // return c,errors.New("invalid length for `body`")
  598. // }
  599. // If there's a "send_at" date, it should be in the future.
  600. if c.SendAt.Valid {
  601. if c.SendAt.Time.Before(time.Now()) {
  602. return c, errors.New(app.i18n.T("campaigns.fieldInvalidSendAt"))
  603. }
  604. }
  605. if len(c.ListIDs) == 0 {
  606. return c, errors.New(app.i18n.T("campaigns.fieldInvalidListIDs"))
  607. }
  608. if !app.manager.HasMessenger(c.Messenger) {
  609. return c, errors.New(app.i18n.Ts("campaigns.fieldInvalidMessenger", "name", c.Messenger))
  610. }
  611. camp := models.Campaign{Body: c.Body, TemplateBody: tplTag}
  612. if err := c.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil {
  613. return c, errors.New(app.i18n.Ts("campaigns.fieldInvalidBody", "error", err.Error()))
  614. }
  615. return c, nil
  616. }
  617. // isCampaignalMutable tells if a campaign's in a state where it's
  618. // properties can be mutated.
  619. func isCampaignalMutable(status string) bool {
  620. return status == models.CampaignStatusRunning ||
  621. status == models.CampaignStatusCancelled ||
  622. status == models.CampaignStatusFinished
  623. }
  624. // makeOptinCampaignMessage makes a default opt-in campaign message body.
  625. func makeOptinCampaignMessage(o campaignReq, app *App) (campaignReq, error) {
  626. if len(o.ListIDs) == 0 {
  627. return o, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.fieldInvalidListIDs"))
  628. }
  629. // Fetch double opt-in lists from the given list IDs.
  630. var lists []models.List
  631. err := app.queries.GetListsByOptin.Select(&lists, models.ListOptinDouble, pq.Int64Array(o.ListIDs), nil)
  632. if err != nil {
  633. app.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err))
  634. return o, echo.NewHTTPError(http.StatusInternalServerError,
  635. app.i18n.Ts("globals.messages.errorFetching",
  636. "name", "{globals.terms.list}", "error", pqErrMsg(err)))
  637. }
  638. // No opt-in lists.
  639. if len(lists) == 0 {
  640. return o, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.noOptinLists"))
  641. }
  642. // Construct the opt-in URL with list IDs.
  643. listIDs := url.Values{}
  644. for _, l := range lists {
  645. listIDs.Add("l", l.UUID)
  646. }
  647. // optinURLFunc := template.URL("{{ OptinURL }}?" + listIDs.Encode())
  648. optinURLAttr := template.HTMLAttr(fmt.Sprintf(`href="{{ OptinURL }}%s"`, listIDs.Encode()))
  649. // Prepare sample opt-in message for the campaign.
  650. var b bytes.Buffer
  651. if err := app.notifTpls.ExecuteTemplate(&b, "optin-campaign", struct {
  652. Lists []models.List
  653. OptinURLAttr template.HTMLAttr
  654. }{lists, optinURLAttr}); err != nil {
  655. app.log.Printf("error compiling 'optin-campaign' template: %v", err)
  656. return o, echo.NewHTTPError(http.StatusBadRequest,
  657. app.i18n.Ts("templates.errorCompiling", "error", err.Error()))
  658. }
  659. o.Body = b.String()
  660. return o, nil
  661. }
  662. // makeCampaignQuery cleans an optional campaign search string and prepares the
  663. // campaign SQL statement (string) and returns them.
  664. func makeCampaignQuery(q, orderBy, order, query string) (string, string) {
  665. if q != "" {
  666. q = `%` + string(regexFullTextQuery.ReplaceAll([]byte(q), []byte("&"))) + `%`
  667. }
  668. // Sort params.
  669. if !strSliceContains(orderBy, campaignQuerySortFields) {
  670. orderBy = "created_at"
  671. }
  672. if order != sortAsc && order != sortDesc {
  673. order = sortDesc
  674. }
  675. return q, fmt.Sprintf(query, orderBy, order)
  676. }