diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3bf7d35 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,38 @@ +name: goreleaser + +on: + push: + tags: + - "v*" # Will trigger only if tag is pushed matching pattern `v*` (Eg: `v0.1.0`) + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v2 + with: + go-version: 1.16 + + - name: Login to Docker Registry + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Prepare Dependencies + run: | + make deps + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v2 + with: + version: latest + args: --parallelism 1 --rm-dist --skip-validate + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.goreleaser.yml b/.goreleaser.yml index 669d5df..5d108f3 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -35,7 +35,7 @@ dockers: - goos: linux goarch: amd64 - binaries: + ids: - listmonk image_templates: - "listmonk/listmonk:latest" diff --git a/Dockerfile b/Dockerfile index fd7567b..1e4e221 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,3 +5,4 @@ COPY listmonk . COPY config.toml.sample config.toml COPY config-demo.toml . CMD ["./listmonk"] +EXPOSE 9000 diff --git a/Makefile b/Makefile index f8be343..0e56377 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ deps: # Build the backend to ./listmonk. .PHONY: build build: - go build -o ${BIN} -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}'" cmd/*.go + CGO_ENABLED=0 go build -o ${BIN} -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}'" cmd/*.go # Run the backend. .PHONY: run diff --git a/README.md b/README.md index 008f42a..32943c1 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,11 @@ Visit [listmonk.app](https://listmonk.app) The latest image is available on DockerHub at `listmonk/listmonk:latest`. Use the sample [docker-compose.yml](https://github.com/knadh/listmonk/blob/master/docker-compose.yml) to run listmonk and Postgres DB with docker-compose as follows: #### Demo -`docker-compose up -d demo-db demo-app` + +```bash +mkdir listmonk-demo +sh -c "$(curl -sSL https://raw.githubusercontent.com/knadh/listmonk/master/install-demo.sh)" +``` The demo does not persist Postgres after the containers are removed. DO NOT use this demo setup in production. diff --git a/cmd/admin.go b/cmd/admin.go index 289ef20..292226b 100644 --- a/cmd/admin.go +++ b/cmd/admin.go @@ -1,8 +1,6 @@ package main import ( - "bytes" - "encoding/json" "fmt" "net/http" "sort" @@ -13,41 +11,29 @@ import ( "github.com/labstack/echo" ) -type configScript struct { - RootURL string `json:"rootURL"` - FromEmail string `json:"fromEmail"` - Messengers []string `json:"messengers"` - MediaProvider string `json:"mediaProvider"` - NeedsRestart bool `json:"needsRestart"` - Update *AppUpdate `json:"update"` - Langs []i18nLang `json:"langs"` - EnablePublicSubPage bool `json:"enablePublicSubscriptionPage"` - Lang json.RawMessage `json:"lang"` +type serverConfig struct { + Messengers []string `json:"messengers"` + Langs []i18nLang `json:"langs"` + Lang string `json:"lang"` + Update *AppUpdate `json:"update"` + NeedsRestart bool `json:"needs_restart"` } -// handleGetConfigScript returns general configuration as a Javascript -// variable that can be included in an HTML page directly. -func handleGetConfigScript(c echo.Context) error { +// handleGetServerConfig returns general server config. +func handleGetServerConfig(c echo.Context) error { var ( app = c.Get("app").(*App) - out = configScript{ - RootURL: app.constants.RootURL, - FromEmail: app.constants.FromEmail, - MediaProvider: app.constants.MediaProvider, - EnablePublicSubPage: app.constants.EnablePublicSubPage, - } + out = serverConfig{} ) // Language list. - langList, err := geti18nLangList(app.constants.Lang, app) + langList, err := getI18nLangList(app.constants.Lang, app) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Error loading language list: %v", err)) } out.Langs = langList - - // Current language. - out.Lang = json.RawMessage(app.i18n.JSON()) + out.Lang = app.constants.Lang // Sort messenger names with `email` always as the first item. var names []string @@ -66,17 +52,7 @@ func handleGetConfigScript(c echo.Context) error { out.Update = app.update app.Unlock() - // Write the Javascript variable opening; - b := bytes.Buffer{} - b.Write([]byte(`var CONFIG = `)) - - // Encode the config payload as JSON and write as the variable's value assignment. - j := json.NewEncoder(&b) - if err := j.Encode(out); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("admin.errorMarshallingConfig", "error", err.Error())) - } - return c.Blob(http.StatusOK, "application/javascript; charset=utf-8", b.Bytes()) + return c.JSON(http.StatusOK, okResp{out}) } // handleGetDashboardCharts returns chart data points to render ont he dashboard. diff --git a/cmd/campaigns.go b/cmd/campaigns.go index 65cc99b..3a03aed 100644 --- a/cmd/campaigns.go +++ b/cmd/campaigns.go @@ -155,16 +155,14 @@ func handlePreviewCampaign(c echo.Context) error { var ( app = c.Get("app").(*App) id, _ = strconv.Atoi(c.Param("id")) - body = c.FormValue("body") - - camp = &models.Campaign{} ) if id < 1 { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } - err := app.queries.GetCampaignForPreview.Get(camp, id) + var camp models.Campaign + err := app.queries.GetCampaignForPreview.Get(&camp, id) if err != nil { if err == sql.ErrNoRows { return echo.NewHTTPError(http.StatusBadRequest, @@ -177,6 +175,12 @@ func handlePreviewCampaign(c echo.Context) error { "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } + // There's a body in the request to preview instead of the body in the DB. + if c.Request().Method == http.MethodPost { + camp.ContentType = c.FormValue("content_type") + camp.Body = c.FormValue("body") + } + var sub models.Subscriber // Get a random subscriber from the campaign. if err := app.queries.GetOneCampaignSubscriber.Get(&sub, camp.ID); err != nil { @@ -191,19 +195,14 @@ func handlePreviewCampaign(c echo.Context) error { } } - // Compile the template. - if body != "" { - camp.Body = body - } - - if err := camp.CompileTemplate(app.manager.TemplateFuncs(camp)); err != nil { + if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil { app.log.Printf("error compiling template: %v", err) return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("templates.errorCompiling", "error", err.Error())) } // Render the message body. - m := app.manager.NewCampaignMessage(camp, sub) + m := app.manager.NewCampaignMessage(&camp, sub) if err := m.Render(); err != nil { app.log.Printf("error rendering message: %v", err) return echo.NewHTTPError(http.StatusBadRequest, diff --git a/cmd/handlers.go b/cmd/handlers.go index a399460..0a0d9b7 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -2,8 +2,6 @@ package main import ( "crypto/subtle" - "encoding/json" - "fmt" "net/http" "net/url" "regexp" @@ -39,13 +37,21 @@ var ( ) // registerHandlers registers HTTP handlers. -func registerHTTPHandlers(e *echo.Echo) { +func registerHTTPHandlers(e *echo.Echo, app *App) { // Group of private handlers with BasicAuth. - g := e.Group("", middleware.BasicAuth(basicAuth)) + var g *echo.Group + + if len(app.constants.AdminUsername) == 0 || + len(app.constants.AdminPassword) == 0 { + g = e.Group("") + } else { + g = e.Group("", middleware.BasicAuth(basicAuth)) + } + g.GET("/", handleIndexPage) g.GET("/api/health", handleHealthCheck) - g.GET("/api/config.js", handleGetConfigScript) - g.GET("/api/lang/:lang", handleLoadLanguage) + g.GET("/api/config", handleGetServerConfig) + g.GET("/api/lang/:lang", handleGetI18nLang) g.GET("/api/dashboard/charts", handleGetDashboardCharts) g.GET("/api/dashboard/counts", handleGetDashboardCounts) @@ -164,23 +170,6 @@ func handleHealthCheck(c echo.Context) error { return c.JSON(http.StatusOK, okResp{true}) } -// handleLoadLanguage returns the JSON language pack given the language code. -func handleLoadLanguage(c echo.Context) error { - app := c.Get("app").(*App) - - lang := c.Param("lang") - if len(lang) > 6 || reLangCode.MatchString(lang) { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid language code.") - } - - b, err := app.fs.Read(fmt.Sprintf("/lang/%s.json", lang)) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Unknown language.") - } - - return c.JSON(http.StatusOK, okResp{json.RawMessage(b)}) -} - // basicAuth middleware does an HTTP BasicAuth authentication for admin handlers. func basicAuth(username, password string, c echo.Context) (bool, error) { app := c.Get("app").(*App) diff --git a/cmd/i18n.go b/cmd/i18n.go index f91b2f8..66bb0e9 100644 --- a/cmd/i18n.go +++ b/cmd/i18n.go @@ -3,6 +3,11 @@ package main import ( "encoding/json" "fmt" + "net/http" + + "github.com/knadh/listmonk/internal/i18n" + "github.com/knadh/stuffbin" + "github.com/labstack/echo" ) type i18nLang struct { @@ -15,8 +20,25 @@ type i18nLangRaw struct { Name string `json:"_.name"` } -// geti18nLangList returns the list of available i18n languages. -func geti18nLangList(lang string, app *App) ([]i18nLang, error) { +// handleGetI18nLang returns the JSON language pack given the language code. +func handleGetI18nLang(c echo.Context) error { + app := c.Get("app").(*App) + + lang := c.Param("lang") + if len(lang) > 6 || reLangCode.MatchString(lang) { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid language code.") + } + + i, err := getI18nLang(lang, app.fs) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Unknown language.") + } + + return c.JSON(http.StatusOK, okResp{json.RawMessage(i.JSON())}) +} + +// getI18nLangList returns the list of available i18n languages. +func getI18nLangList(lang string, app *App) ([]i18nLang, error) { list, err := app.fs.Glob("/i18n/*.json") if err != nil { return nil, err @@ -42,3 +64,30 @@ func geti18nLangList(lang string, app *App) ([]i18nLang, error) { return out, nil } + +func getI18nLang(lang string, fs stuffbin.FileSystem) (*i18n.I18n, error) { + const def = "en" + + b, err := fs.Read(fmt.Sprintf("/i18n/%s.json", def)) + if err != nil { + return nil, fmt.Errorf("error reading default i18n language file: %s: %v", def, err) + } + + // Initialize with the default language. + i, err := i18n.New(b) + if err != nil { + return nil, fmt.Errorf("error unmarshalling i18n language: %v", err) + } + + // Load the selected language on top of it. + b, err = fs.Read(fmt.Sprintf("/i18n/%s.json", lang)) + if err != nil { + return nil, fmt.Errorf("error reading i18n language file: %v", err) + } + + if err := i.Load(b); err != nil { + return nil, fmt.Errorf("error loading i18n language file: %v", err) + } + + return i, nil +} diff --git a/cmd/init.go b/cmd/init.go index fab4fee..da8164b 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -262,28 +262,10 @@ func initConstants() *constants { // and then the selected language is loaded on top of it so that if there are // missing translations in it, the default English translations show up. func initI18n(lang string, fs stuffbin.FileSystem) *i18n.I18n { - const def = "en" - - b, err := fs.Read(fmt.Sprintf("/i18n/%s.json", def)) + i, err := getI18nLang(lang, fs) if err != nil { - lo.Fatalf("error reading default i18n language file: %s: %v", def, err) + lo.Fatal(err) } - - // Initialize with the default language. - i, err := i18n.New(b) - if err != nil { - lo.Fatalf("error unmarshalling i18n language: %v", err) - } - - // Load the selected language on top of it. - b, err = fs.Read(fmt.Sprintf("/i18n/%s.json", lang)) - if err != nil { - lo.Fatalf("error reading i18n language file: %v", err) - } - if err := i.Load(b); err != nil { - lo.Fatalf("error loading i18n language file: %v", err) - } - return i } @@ -505,7 +487,7 @@ func initHTTPServer(app *App) *echo.Echo { } // Register all HTTP handlers. - registerHTTPHandlers(srv) + registerHTTPHandlers(srv, app) // Start the server. go func() { diff --git a/cmd/public.go b/cmd/public.go index fdecbb4..4c80a12 100644 --- a/cmd/public.go +++ b/cmd/public.go @@ -118,15 +118,14 @@ func handleViewCampaignMessage(c echo.Context) error { } // Get the subscriber. - var sub models.Subscriber - if err := app.queries.GetSubscriber.Get(&sub, 0, subUUID); err != nil { + sub, err := getSubscriber(0, subUUID, "", app) + if err != nil { if err == sql.ErrNoRows { return c.Render(http.StatusNotFound, tplMessage, makeMsgTpl(app.i18n.T("public.notFoundTitle"), "", app.i18n.T("public.errorFetchingEmail"))) } - app.log.Printf("error fetching campaign subscriber: %v", err) return c.Render(http.StatusInternalServerError, tplMessage, makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign"))) @@ -324,14 +323,18 @@ func handleSubscriptionForm(c echo.Context) error { // Insert the subscriber into the DB. req.Status = models.SubscriberStatusEnabled req.ListUUIDs = pq.StringArray(req.SubListUUIDs) - if _, err := insertSubscriber(req.SubReq, app); err != nil && err != errSubscriberExists { + _, _, hasOptin, err := insertSubscriber(req.SubReq, app) + if err != nil { return c.Render(http.StatusInternalServerError, tplMessage, makeMsgTpl(app.i18n.T("public.errorTitle"), "", fmt.Sprintf("%s", err.(*echo.HTTPError).Message))) } - return c.Render(http.StatusOK, tplMessage, - makeMsgTpl(app.i18n.T("public.subTitle"), "", - app.i18n.Ts("public.subConfirmed"))) + msg := "public.subConfirmed" + if hasOptin { + msg = "public.subOptinPending" + } + + return c.Render(http.StatusOK, tplMessage, makeMsgTpl(app.i18n.T("public.subTitle"), "", app.i18n.Ts(msg))) } // handleLinkRedirect redirects a link UUID to its original underlying link diff --git a/cmd/subscribers.go b/cmd/subscribers.go index b36703c..84d07bf 100644 --- a/cmd/subscribers.go +++ b/cmd/subscribers.go @@ -42,6 +42,13 @@ type subsWrap struct { Page int `json:"page"` } +type subUpdateReq struct { + models.Subscriber + RawAttribs json.RawMessage `json:"attribs"` + Lists pq.Int64Array `json:"lists"` + ListUUIDs pq.StringArray `json:"list_uuids"` +} + // subProfileData represents a subscriber's collated data in JSON // for export. type subProfileData struct { @@ -79,7 +86,11 @@ func handleGetSubscriber(c echo.Context) error { id, _ = strconv.Atoi(c.Param("id")) ) - sub, err := getSubscriber(id, app) + if id < 1 { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) + } + + sub, err := getSubscriber(id, "", "", app) if err != nil { return err } @@ -273,14 +284,13 @@ func handleCreateSubscriber(c echo.Context) error { } // Insert the subscriber into the DB. - sub, err := insertSubscriber(req, app) + sub, isNew, _, err := insertSubscriber(req, app) if err != nil { - if err == errSubscriberExists { - return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.emailExists")) - } - return err } + if !isNew { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.emailExists")) + } return c.JSON(http.StatusOK, okResp{sub}) } @@ -290,7 +300,7 @@ func handleUpdateSubscriber(c echo.Context) error { var ( app = c.Get("app").(*App) id, _ = strconv.ParseInt(c.Param("id"), 10, 64) - req subimporter.SubReq + req subUpdateReq ) // Get and validate fields. if err := c.Bind(&req); err != nil { @@ -307,11 +317,21 @@ func handleUpdateSubscriber(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidName")) } - _, err := app.queries.UpdateSubscriber.Exec(req.ID, + // If there's an attribs value, validate it. + if len(req.RawAttribs) > 0 { + var a models.SubscriberAttribs + if err := json.Unmarshal(req.RawAttribs, &a); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts("globals.messages.errorUpdating", + "name", "{globals.terms.subscriber}", "error", err.Error())) + } + } + + _, err := app.queries.UpdateSubscriber.Exec(id, strings.ToLower(strings.TrimSpace(req.Email)), strings.TrimSpace(req.Name), req.Status, - req.Attribs, + req.RawAttribs, req.Lists) if err != nil { app.log.Printf("error updating subscriber: %v", err) @@ -321,11 +341,11 @@ func handleUpdateSubscriber(c echo.Context) error { } // Send a confirmation e-mail (if there are any double opt-in lists). - sub, err := getSubscriber(int(id), app) + sub, err := getSubscriber(int(id), "", "", app) if err != nil { return err } - _ = sendOptinConfirmation(sub, []int64(req.Lists), app) + _, _ = sendOptinConfirmation(sub, []int64(req.Lists), app) return c.JSON(http.StatusOK, okResp{sub}) } @@ -335,7 +355,6 @@ func handleSubscriberSendOptin(c echo.Context) error { var ( app = c.Get("app").(*App) id, _ = strconv.Atoi(c.Param("id")) - out models.Subscribers ) if id < 1 { @@ -343,19 +362,15 @@ func handleSubscriberSendOptin(c echo.Context) error { } // Fetch the subscriber. - err := app.queries.GetSubscriber.Select(&out, id, nil) + out, err := getSubscriber(id, "", "", app) if err != nil { app.log.Printf("error fetching subscriber: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) } - if len(out) == 0 { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.subscriber}")) - } - if err := sendOptinConfirmation(out[0], nil, app); err != nil { + if _, err := sendOptinConfirmation(out, nil, app); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, app.i18n.T("subscribers.errorSendingOptin")) } @@ -619,56 +634,53 @@ func handleExportSubscriberData(c echo.Context) error { return c.Blob(http.StatusOK, "application/json", b) } -// insertSubscriber inserts a subscriber and returns the ID. -func insertSubscriber(req subimporter.SubReq, app *App) (models.Subscriber, error) { +// insertSubscriber inserts a subscriber and returns the ID. The first bool indicates if +// it was a new subscriber, and the second bool indicates if the subscriber was sent an optin confirmation. +func insertSubscriber(req subimporter.SubReq, app *App) (models.Subscriber, bool, bool, error) { uu, err := uuid.NewV4() if err != nil { - return req.Subscriber, err + return req.Subscriber, false, false, err } req.UUID = uu.String() - err = app.queries.InsertSubscriber.Get(&req.ID, + isNew := true + if err = app.queries.InsertSubscriber.Get(&req.ID, req.UUID, req.Email, strings.TrimSpace(req.Name), req.Status, req.Attribs, req.Lists, - req.ListUUIDs) - if err != nil { + req.ListUUIDs); err != nil { if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "subscribers_email_key" { - return req.Subscriber, errSubscriberExists + isNew = false + } else { + // return req.Subscriber, errSubscriberExists + app.log.Printf("error inserting subscriber: %v", err) + return req.Subscriber, false, false, echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts("globals.messages.errorCreating", + "name", "{globals.terms.subscriber}", "error", pqErrMsg(err))) } - - app.log.Printf("error inserting subscriber: %v", err) - return req.Subscriber, echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorCreating", - "name", "{globals.terms.subscriber}", "error", pqErrMsg(err))) } - // Fetch the subscriber's full data. - sub, err := getSubscriber(req.ID, app) + // Fetch the subscriber's full data. If the subscriber already existed and wasn't + // created, the id will be empty. Fetch the details by e-mail then. + sub, err := getSubscriber(req.ID, "", strings.ToLower(req.Email), app) if err != nil { - return sub, err + return sub, false, false, err } // Send a confirmation e-mail (if there are any double opt-in lists). - _ = sendOptinConfirmation(sub, []int64(req.Lists), app) - return sub, nil + num, _ := sendOptinConfirmation(sub, []int64(req.Lists), app) + return sub, isNew, num > 0, nil } -// getSubscriber gets a single subscriber by ID. -func getSubscriber(id int, app *App) (models.Subscriber, error) { - var ( - out models.Subscribers - ) +// getSubscriber gets a single subscriber by ID, uuid, or e-mail in that order. +// Only one of these params should have a value. +func getSubscriber(id int, uuid, email string, app *App) (models.Subscriber, error) { + var out models.Subscribers - if id < 1 { - return models.Subscriber{}, - echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) - } - - if err := app.queries.GetSubscriber.Select(&out, id, nil); err != nil { + if err := app.queries.GetSubscriber.Select(&out, id, uuid, email); err != nil { app.log.Printf("error fetching subscriber: %v", err) return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", @@ -733,8 +745,9 @@ func exportSubscriberData(id int64, subUUID string, exportables map[string]bool, } // sendOptinConfirmation sends a double opt-in confirmation e-mail to a subscriber -// if at least one of the given listIDs is set to optin=double -func sendOptinConfirmation(sub models.Subscriber, listIDs []int64, app *App) error { +// if at least one of the given listIDs is set to optin=double. It returns the number of +// opt-in lists that were found. +func sendOptinConfirmation(sub models.Subscriber, listIDs []int64, app *App) (int, error) { var lists []models.List // Fetch double opt-in lists from the given list IDs. @@ -742,12 +755,12 @@ func sendOptinConfirmation(sub models.Subscriber, listIDs []int64, app *App) err if err := app.queries.GetSubscriberLists.Select(&lists, sub.ID, nil, pq.Int64Array(listIDs), nil, models.SubscriptionStatusUnconfirmed, models.ListOptinDouble); err != nil { app.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err)) - return err + return 0, err } // None. if len(lists) == 0 { - return nil + return 0, nil } var ( @@ -764,9 +777,9 @@ func sendOptinConfirmation(sub models.Subscriber, listIDs []int64, app *App) err if err := app.sendNotification([]string{sub.Email}, app.i18n.T("subscribers.optinSubject"), notifSubscriberOptin, out); err != nil { app.log.Printf("error sending opt-in e-mail: %s", err) - return err + return 0, err } - return nil + return len(lists), nil } // sanitizeSQLExp does basic sanitisation on arbitrary diff --git a/cmd/templates.go b/cmd/templates.go index 6977b9a..637989f 100644 --- a/cmd/templates.go +++ b/cmd/templates.go @@ -177,8 +177,7 @@ func handleUpdateTemplate(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } - // TODO: PASSWORD HASHING. - res, err := app.queries.UpdateTemplate.Exec(o.ID, o.Name, o.Body) + res, err := app.queries.UpdateTemplate.Exec(id, o.Name, o.Body) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, app.i18n.Ts("globals.messages.errorUpdating", diff --git a/cmd/updates.go b/cmd/updates.go index 09141b4..73f327d 100644 --- a/cmd/updates.go +++ b/cmd/updates.go @@ -34,6 +34,8 @@ func checkUpdates(curVersion string, interval time.Duration, app *App) { curVersion = reSemver.ReplaceAllString(curVersion, "") time.Sleep(time.Second * 1) ticker := time.NewTicker(interval) + defer ticker.Stop() + for ; true; <-ticker.C { resp, err := http.Get(updateCheckURL) if err != nil { diff --git a/cmd/upgrade.go b/cmd/upgrade.go index 8fb7603..72e6b8f 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -29,6 +29,7 @@ var migList = []migFunc{ {"v0.7.0", migrations.V0_7_0}, {"v0.8.0", migrations.V0_8_0}, {"v0.9.0", migrations.V0_9_0}, + {"v1.0.0", migrations.V1_0_0}, } // upgrade upgrades the database to the current version by running SQL migration files diff --git a/docker-compose.yml b/docker-compose.yml index 4a93e13..77fbf59 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,16 +13,16 @@ x-app-defaults: &app-defaults - listmonk x-db-defaults: &db-defaults - image: postgres:11 - ports: - - "9432:5432" - networks: - - listmonk - environment: - - POSTGRES_PASSWORD=listmonk - - POSTGRES_USER=listmonk - - POSTGRES_DB=listmonk - restart: unless-stopped + image: postgres:11 + ports: + - "9432:5432" + networks: + - listmonk + environment: + - POSTGRES_PASSWORD=listmonk + - POSTGRES_USER=listmonk + - POSTGRES_DB=listmonk + restart: unless-stopped services: db: @@ -43,7 +43,7 @@ services: demo-app: <<: *app-defaults command: [sh, -c, "yes | ./listmonk --install --config config-demo.toml && ./listmonk --config config-demo.toml"] - depends_on: + depends_on: - demo-db networks: diff --git a/frontend/README.md b/frontend/README.md index 51964ae..b703d7a 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -5,16 +5,20 @@ It's best if the `listmonk/frontend` directory is opened in an IDE as a separate For developer setup instructions, refer to the main project's README. ## Globals -`main.js` is where Buefy is injected globally into Vue. In addition two controllers, `$api` (collection of API calls from `api/index.js`), `$utils` (util functions from `util.js`), `$serverConfig` (loaded form /api/config.js) are also attached globaly to Vue. They are accessible within Vue as `this.$api` and `this.$utils`. +In `main.js`, Buefy and vue-i18n are attached globally. In addition: + +- `$api` (collection of API calls from `api/index.js`) +- `$utils` (util functions from `util.js`). They are accessible within Vue as `this.$api` and `this.$utils`. Some constants are defined in `constants.js`. + ## APIs and states The project uses a global `vuex` state to centrally store the responses to pretty much all APIs (eg: fetch lists, campaigns etc.) except for a few exceptions. These are called `models` and have been defined in `constants.js`. The definitions are in `store/index.js`. There is a global state `loading` (eg: loading.campaigns, loading.lists) that indicates whether an API call for that particular "model" is running. This can be used anywhere in the project to show loading spinners for instance. All the API definitions are in `api/index.js`. It also describes how each API call sets the global `loading` status alongside storing the API responses. -*IMPORTANT*: All JSON field names in GET API responses are automatically camel-cased when they're pulled for the sake of consistentcy in the frontend code and for complying with the linter spec in the project (Vue/AirBnB schema). For example, `content_type` becomes `contentType`. When sending responses to the backend, however, they should be snake-cased manually. +*IMPORTANT*: All JSON field names in GET API responses are automatically camel-cased when they're pulled for the sake of consistentcy in the frontend code and for complying with the linter spec in the project (Vue/AirBnB schema). For example, `content_type` becomes `contentType`. When sending responses to the backend, however, they should be snake-cased manually. This is overridden for certain calls such as `/api/config` and `/api/settings` using the `preserveCase: true` param in `api/index.js`. ## Icon pack diff --git a/frontend/cypress.json b/frontend/cypress.json new file mode 100644 index 0000000..f8db83a --- /dev/null +++ b/frontend/cypress.json @@ -0,0 +1,8 @@ +{ + "baseUrl": "http://localhost:9000", + "env": { + "server_init_command": "pkill -9 listmonk | cd ../ && ./listmonk --install --yes && ./listmonk > /dev/null 2>/dev/null &", + "username": "listmonk", + "password": "listmonk" + } +} diff --git a/frontend/cypress/downloads/data.json b/frontend/cypress/downloads/data.json new file mode 100644 index 0000000..81f86d4 --- /dev/null +++ b/frontend/cypress/downloads/data.json @@ -0,0 +1,28 @@ +{ + "profile": [ + { + "id": 2, + "uuid": "0954ba2e-50e4-4847-86f4-c2b8b72dace8", + "email": "anon@example.com", + "name": "Anon Doe", + "attribs": { + "city": "Bengaluru", + "good": true, + "type": "unknown" + }, + "status": "enabled", + "created_at": "2021-02-20T15:52:16.251648+05:30", + "updated_at": "2021-02-20T15:52:16.251648+05:30" + } + ], + "subscriptions": [ + { + "subscription_status": "unconfirmed", + "name": "Opt-in list", + "type": "public", + "created_at": "2021-02-20T15:52:16.251648+05:30" + } + ], + "campaign_views": [], + "link_clicks": [] +} \ No newline at end of file diff --git a/frontend/cypress/fixtures/subs.csv b/frontend/cypress/fixtures/subs.csv new file mode 100644 index 0000000..d792aaf --- /dev/null +++ b/frontend/cypress/fixtures/subs.csv @@ -0,0 +1,101 @@ +email,name,attributes +user0@mail.com,First0 Last0,"{""age"": 29, ""city"": ""Bangalore"", ""clientId"": ""DAXX79""}" +user1@mail.com,First1 Last1,"{""age"": 43, ""city"": ""Bangalore"", ""clientId"": ""DAXX71""}" +user2@mail.com,First2 Last2,"{""age"": 47, ""city"": ""Bangalore"", ""clientId"": ""DAXX70""}" +user3@mail.com,First3 Last3,"{""age"": 67, ""city"": ""Bangalore"", ""clientId"": ""DAXX32""}" +user4@mail.com,First4 Last4,"{""age"": 63, ""city"": ""Bangalore"", ""clientId"": ""DAXX30""}" +user5@mail.com,First5 Last5,"{""age"": 69, ""city"": ""Bangalore"", ""clientId"": ""DAXX64""}" +user6@mail.com,First6 Last6,"{""age"": 68, ""city"": ""Bangalore"", ""clientId"": ""DAXX22""}" +user7@mail.com,First7 Last7,"{""age"": 56, ""city"": ""Bangalore"", ""clientId"": ""DAXX54""}" +user8@mail.com,First8 Last8,"{""age"": 58, ""city"": ""Bangalore"", ""clientId"": ""DAXX65""}" +user9@mail.com,First9 Last9,"{""age"": 51, ""city"": ""Bangalore"", ""clientId"": ""DAXX66""}" +user10@mail.com,First10 Last10,"{""age"": 53, ""city"": ""Bangalore"", ""clientId"": ""DAXX31""}" +user11@mail.com,First11 Last11,"{""age"": 46, ""city"": ""Bangalore"", ""clientId"": ""DAXX59""}" +user12@mail.com,First12 Last12,"{""age"": 41, ""city"": ""Bangalore"", ""clientId"": ""DAXX80""}" +user13@mail.com,First13 Last13,"{""age"": 27, ""city"": ""Bangalore"", ""clientId"": ""DAXX96""}" +user14@mail.com,First14 Last14,"{""age"": 51, ""city"": ""Bangalore"", ""clientId"": ""DAXX22""}" +user15@mail.com,First15 Last15,"{""age"": 31, ""city"": ""Bangalore"", ""clientId"": ""DAXX97""}" +user16@mail.com,First16 Last16,"{""age"": 59, ""city"": ""Bangalore"", ""clientId"": ""DAXX41""}" +user17@mail.com,First17 Last17,"{""age"": 29, ""city"": ""Bangalore"", ""clientId"": ""DAXX93""}" +user18@mail.com,First18 Last18,"{""age"": 39, ""city"": ""Bangalore"", ""clientId"": ""DAXX35""}" +user19@mail.com,First19 Last19,"{""age"": 67, ""city"": ""Bangalore"", ""clientId"": ""DAXX21""}" +user20@mail.com,First20 Last20,"{""age"": 66, ""city"": ""Bangalore"", ""clientId"": ""DAXX56""}" +user21@mail.com,First21 Last21,"{""age"": 39, ""city"": ""Bangalore"", ""clientId"": ""DAXX26""}" +user22@mail.com,First22 Last22,"{""age"": 44, ""city"": ""Bangalore"", ""clientId"": ""DAXX98""}" +user23@mail.com,First23 Last23,"{""age"": 66, ""city"": ""Bangalore"", ""clientId"": ""DAXX64""}" +user24@mail.com,First24 Last24,"{""age"": 48, ""city"": ""Bangalore"", ""clientId"": ""DAXX41""}" +user25@mail.com,First25 Last25,"{""age"": 38, ""city"": ""Bangalore"", ""clientId"": ""DAXX80""}" +user26@mail.com,First26 Last26,"{""age"": 27, ""city"": ""Bangalore"", ""clientId"": ""DAXX26""}" +user27@mail.com,First27 Last27,"{""age"": 59, ""city"": ""Bangalore"", ""clientId"": ""DAXX55""}" +user28@mail.com,First28 Last28,"{""age"": 49, ""city"": ""Bangalore"", ""clientId"": ""DAXX45""}" +user29@mail.com,First29 Last29,"{""age"": 45, ""city"": ""Bangalore"", ""clientId"": ""DAXX74""}" +user30@mail.com,First30 Last30,"{""age"": 47, ""city"": ""Bangalore"", ""clientId"": ""DAXX27""}" +user31@mail.com,First31 Last31,"{""age"": 21, ""city"": ""Bangalore"", ""clientId"": ""DAXX37""}" +user32@mail.com,First32 Last32,"{""age"": 21, ""city"": ""Bangalore"", ""clientId"": ""DAXX50""}" +user33@mail.com,First33 Last33,"{""age"": 70, ""city"": ""Bangalore"", ""clientId"": ""DAXX29""}" +user34@mail.com,First34 Last34,"{""age"": 59, ""city"": ""Bangalore"", ""clientId"": ""DAXX95""}" +user35@mail.com,First35 Last35,"{""age"": 36, ""city"": ""Bangalore"", ""clientId"": ""DAXX79""}" +user36@mail.com,First36 Last36,"{""age"": 47, ""city"": ""Bangalore"", ""clientId"": ""DAXX30""}" +user37@mail.com,First37 Last37,"{""age"": 36, ""city"": ""Bangalore"", ""clientId"": ""DAXX92""}" +user38@mail.com,First38 Last38,"{""age"": 29, ""city"": ""Bangalore"", ""clientId"": ""DAXX48""}" +user39@mail.com,First39 Last39,"{""age"": 23, ""city"": ""Bangalore"", ""clientId"": ""DAXX12""}" +user40@mail.com,First40 Last40,"{""age"": 39, ""city"": ""Bangalore"", ""clientId"": ""DAXX40""}" +user41@mail.com,First41 Last41,"{""age"": 41, ""city"": ""Bangalore"", ""clientId"": ""DAXX51""}" +user42@mail.com,First42 Last42,"{""age"": 22, ""city"": ""Bangalore"", ""clientId"": ""DAXX49""}" +user43@mail.com,First43 Last43,"{""age"": 68, ""city"": ""Bangalore"", ""clientId"": ""DAXX58""}" +user44@mail.com,First44 Last44,"{""age"": 45, ""city"": ""Bangalore"", ""clientId"": ""DAXX15""}" +user45@mail.com,First45 Last45,"{""age"": 44, ""city"": ""Bangalore"", ""clientId"": ""DAXX75""}" +user46@mail.com,First46 Last46,"{""age"": 42, ""city"": ""Bangalore"", ""clientId"": ""DAXX99""}" +user47@mail.com,First47 Last47,"{""age"": 61, ""city"": ""Bangalore"", ""clientId"": ""DAXX39""}" +user48@mail.com,First48 Last48,"{""age"": 57, ""city"": ""Bangalore"", ""clientId"": ""DAXX13""}" +user49@mail.com,First49 Last49,"{""age"": 28, ""city"": ""Bangalore"", ""clientId"": ""DAXX97""}" +user50@mail.com,First50 Last50,"{""age"": 61, ""city"": ""Bangalore"", ""clientId"": ""DAXX75""}" +user51@mail.com,First51 Last51,"{""age"": 27, ""city"": ""Bangalore"", ""clientId"": ""DAXX55""}" +user52@mail.com,First52 Last52,"{""age"": 62, ""city"": ""Bangalore"", ""clientId"": ""DAXX35""}" +user53@mail.com,First53 Last53,"{""age"": 24, ""city"": ""Bangalore"", ""clientId"": ""DAXX67""}" +user54@mail.com,First54 Last54,"{""age"": 25, ""city"": ""Bangalore"", ""clientId"": ""DAXX36""}" +user55@mail.com,First55 Last55,"{""age"": 39, ""city"": ""Bangalore"", ""clientId"": ""DAXX74""}" +user56@mail.com,First56 Last56,"{""age"": 53, ""city"": ""Bangalore"", ""clientId"": ""DAXX28""}" +user57@mail.com,First57 Last57,"{""age"": 32, ""city"": ""Bangalore"", ""clientId"": ""DAXX36""}" +user58@mail.com,First58 Last58,"{""age"": 64, ""city"": ""Bangalore"", ""clientId"": ""DAXX44""}" +user59@mail.com,First59 Last59,"{""age"": 47, ""city"": ""Bangalore"", ""clientId"": ""DAXX65""}" +user60@mail.com,First60 Last60,"{""age"": 62, ""city"": ""Bangalore"", ""clientId"": ""DAXX11""}" +user61@mail.com,First61 Last61,"{""age"": 24, ""city"": ""Bangalore"", ""clientId"": ""DAXX55""}" +user62@mail.com,First62 Last62,"{""age"": 61, ""city"": ""Bangalore"", ""clientId"": ""DAXX49""}" +user63@mail.com,First63 Last63,"{""age"": 52, ""city"": ""Bangalore"", ""clientId"": ""DAXX83""}" +user64@mail.com,First64 Last64,"{""age"": 38, ""city"": ""Bangalore"", ""clientId"": ""DAXX16""}" +user65@mail.com,First65 Last65,"{""age"": 48, ""city"": ""Bangalore"", ""clientId"": ""DAXX54""}" +user66@mail.com,First66 Last66,"{""age"": 35, ""city"": ""Bangalore"", ""clientId"": ""DAXX74""}" +user67@mail.com,First67 Last67,"{""age"": 70, ""city"": ""Bangalore"", ""clientId"": ""DAXX22""}" +user68@mail.com,First68 Last68,"{""age"": 21, ""city"": ""Bangalore"", ""clientId"": ""DAXX98""}" +user69@mail.com,First69 Last69,"{""age"": 46, ""city"": ""Bangalore"", ""clientId"": ""DAXX24""}" +user70@mail.com,First70 Last70,"{""age"": 58, ""city"": ""Bangalore"", ""clientId"": ""DAXX75""}" +user71@mail.com,First71 Last71,"{""age"": 50, ""city"": ""Bangalore"", ""clientId"": ""DAXX57""}" +user72@mail.com,First72 Last72,"{""age"": 63, ""city"": ""Bangalore"", ""clientId"": ""DAXX30""}" +user73@mail.com,First73 Last73,"{""age"": 54, ""city"": ""Bangalore"", ""clientId"": ""DAXX77""}" +user74@mail.com,First74 Last74,"{""age"": 67, ""city"": ""Bangalore"", ""clientId"": ""DAXX91""}" +user75@mail.com,First75 Last75,"{""age"": 61, ""city"": ""Bangalore"", ""clientId"": ""DAXX30""}" +user76@mail.com,First76 Last76,"{""age"": 50, ""city"": ""Bangalore"", ""clientId"": ""DAXX28""}" +user77@mail.com,First77 Last77,"{""age"": 62, ""city"": ""Bangalore"", ""clientId"": ""DAXX41""}" +user78@mail.com,First78 Last78,"{""age"": 66, ""city"": ""Bangalore"", ""clientId"": ""DAXX18""}" +user79@mail.com,First79 Last79,"{""age"": 40, ""city"": ""Bangalore"", ""clientId"": ""DAXX89""}" +user80@mail.com,First80 Last80,"{""age"": 21, ""city"": ""Bangalore"", ""clientId"": ""DAXX72""}" +user81@mail.com,First81 Last81,"{""age"": 43, ""city"": ""Bangalore"", ""clientId"": ""DAXX31""}" +user82@mail.com,First82 Last82,"{""age"": 33, ""city"": ""Bangalore"", ""clientId"": ""DAXX89""}" +user83@mail.com,First83 Last83,"{""age"": 38, ""city"": ""Bangalore"", ""clientId"": ""DAXX88""}" +user84@mail.com,First84 Last84,"{""age"": 24, ""city"": ""Bangalore"", ""clientId"": ""DAXX77""}" +user85@mail.com,First85 Last85,"{""age"": 27, ""city"": ""Bangalore"", ""clientId"": ""DAXX40""}" +user86@mail.com,First86 Last86,"{""age"": 67, ""city"": ""Bangalore"", ""clientId"": ""DAXX46""}" +user87@mail.com,First87 Last87,"{""age"": 20, ""city"": ""Bangalore"", ""clientId"": ""DAXX53""}" +user88@mail.com,First88 Last88,"{""age"": 45, ""city"": ""Bangalore"", ""clientId"": ""DAXX79""}" +user89@mail.com,First89 Last89,"{""age"": 31, ""city"": ""Bangalore"", ""clientId"": ""DAXX11""}" +user90@mail.com,First90 Last90,"{""age"": 51, ""city"": ""Bangalore"", ""clientId"": ""DAXX71""}" +user91@mail.com,First91 Last91,"{""age"": 49, ""city"": ""Bangalore"", ""clientId"": ""DAXX20""}" +user92@mail.com,First92 Last92,"{""age"": 26, ""city"": ""Bangalore"", ""clientId"": ""DAXX20""}" +user93@mail.com,First93 Last93,"{""age"": 67, ""city"": ""Bangalore"", ""clientId"": ""DAXX64""}" +user94@mail.com,First94 Last94,"{""age"": 60, ""city"": ""Bangalore"", ""clientId"": ""DAXX53""}" +user95@mail.com,First95 Last95,"{""age"": 64, ""city"": ""Bangalore"", ""clientId"": ""DAXX91""}" +user96@mail.com,First96 Last96,"{""age"": 27, ""city"": ""Bangalore"", ""clientId"": ""DAXX53""}" +user97@mail.com,First97 Last97,"{""age"": 29, ""city"": ""Bangalore"", ""clientId"": ""DAXX46""}" +user98@mail.com,First98 Last98,"{""age"": 26, ""city"": ""Bangalore"", ""clientId"": ""DAXX49""}" +user99@mail.com,First99 Last99,"{""age"": 49, ""city"": ""Bangalore"", ""clientId"": ""DAXX26""}" diff --git a/frontend/cypress/integration/campaigns.js b/frontend/cypress/integration/campaigns.js new file mode 100644 index 0000000..a80567e --- /dev/null +++ b/frontend/cypress/integration/campaigns.js @@ -0,0 +1,211 @@ +describe('Subscribers', () => { + it('Opens campaigns page', () => { + cy.resetDB(); + cy.loginAndVisit('/campaigns'); + }); + + + it('Counts campaigns', () => { + cy.get('tbody td[data-label=Status]').should('have.length', 1); + }); + + it('Edits campaign', () => { + cy.get('td[data-label=Status] a').click(); + + // Fill fields. + cy.get('input[name=name]').clear().type('new-name'); + cy.get('input[name=subject]').clear().type('new-subject'); + cy.get('input[name=from_email]').clear().type('new '); + + // Change the list. + cy.get('.list-selector a.delete').click(); + cy.get('.list-selector input').click(); + cy.get('.list-selector .autocomplete a').eq(1).click(); + + // Clear and redo tags. + cy.get('input[name=tags]').type('{backspace}new-tag{enter}'); + + // Enable schedule. + cy.get('[data-cy=btn-send-later] .check').click(); + cy.get('.datepicker input').click(); + cy.get('.datepicker-header .control:nth-child(2) select').select((new Date().getFullYear() + 1).toString()); + cy.get('.datepicker-body a.is-selectable:first').click(); + cy.get('body').click(1, 1); + + // Switch to content tab. + cy.get('.b-tabs nav a').eq(1).click(); + + // Switch format to plain text. + cy.get('label[data-cy=check-plain]').click(); + cy.get('.modal button.is-primary').click(); + + // Enter body value. + cy.get('textarea[name=content]').clear().type('new-content'); + cy.get('button[data-cy=btn-save]').click(); + + // Schedule. + cy.get('button[data-cy=btn-schedule]').click(); + cy.get('.modal button.is-primary').click(); + + cy.wait(250); + + // Verify the changes. + cy.request('/api/campaigns/1').should((response) => { + const { data } = response.body; + expect(data.status).to.equal('scheduled'); + expect(data.name).to.equal('new-name'); + expect(data.subject).to.equal('new-subject'); + expect(data.content_type).to.equal('plain'); + expect(data.altbody).to.equal(null); + expect(data.send_at).to.not.equal(null); + expect(data.body).to.equal('new-content'); + + expect(data.lists.length).to.equal(1); + expect(data.lists[0].id).to.equal(2); + expect(data.tags.length).to.equal(1); + expect(data.tags[0]).to.equal('new-tag'); + }); + + cy.get('tbody td[data-label=Status] .tag.scheduled'); + }); + + it('Clones campaign', () => { + for (let n = 0; n < 3; n++) { + // Clone the campaign. + cy.get('[data-cy=btn-clone]').first().click(); + cy.get('.modal input').clear().type(`clone${n}`).click(); + cy.get('.modal button.is-primary').click(); + cy.wait(250); + cy.clickMenu('all-campaigns'); + cy.wait(100); + + // Verify the newly created row. + cy.get('tbody td[data-label="Name"]').first().contains(`clone${n}`); + } + }); + + + it('Searches campaigns', () => { + cy.get('input[name=query]').clear().type('clone2{enter}'); + cy.get('tbody tr').its('length').should('eq', 1); + cy.get('tbody td[data-label="Name"]').first().contains('clone2'); + cy.get('input[name=query]').clear().type('{enter}'); + }); + + + it('Deletes campaign', () => { + // Delete all visible lists. + cy.get('tbody tr').each(() => { + cy.get('tbody a[data-cy=btn-delete]').first().click(); + cy.get('.modal button.is-primary').click(); + }); + + // Confirm deletion. + cy.get('table tr.is-empty'); + }); + + + it('Adds new campaigns', () => { + const lists = [[1], [1, 2]]; + const cTypes = ['richtext', 'html', 'plain']; + + let n = 0; + cTypes.forEach((c) => { + lists.forEach((l) => { + // Click the 'new button' + cy.get('[data-cy=btn-new]').click(); + cy.wait(100); + + // Fill fields. + cy.get('input[name=name]').clear().type(`name${n}`); + cy.get('input[name=subject]').clear().type(`subject${n}`); + + l.forEach(() => { + cy.get('.list-selector input').click(); + cy.get('.list-selector .autocomplete a').first().click(); + }); + + // Add tags. + for (let i = 0; i < 3; i++) { + cy.get('input[name=tags]').type(`tag${i}{enter}`); + } + + // Hit 'Continue'. + cy.get('button[data-cy=btn-continue]').click(); + cy.wait(250); + + // Insert content. + cy.get('.ql-editor').type(`hello${n} \{\{ .Subscriber.Name \}\}`, { parseSpecialCharSequences: false }); + cy.get('.ql-editor').type('{enter}'); + cy.get('.ql-editor').type('\{\{ .Subscriber.Attribs.city \}\}', { parseSpecialCharSequences: false }); + + // Select content type. + cy.get(`label[data-cy=check-${c}]`).click(); + + // If it's not richtext, there's a "you'll lose formatting" prompt. + if (c !== 'richtext') { + cy.get('.modal button.is-primary').click(); + } + + // Save. + cy.get('button[data-cy=btn-save]').click(); + + cy.clickMenu('all-campaigns'); + cy.wait(250); + + // Verify the newly created campaign in the table. + cy.get('tbody td[data-label="Name"]').first().contains(`name${n}`); + cy.get('tbody td[data-label="Name"]').first().contains(`subject${n}`); + cy.get('tbody td[data-label="Lists"]').first().then(($el) => { + cy.wrap($el).find('li').should('have.length', l.length); + }); + + n++; + }); + }); + + // Fetch the campaigns API and verfiy the values that couldn't be verified on the table UI. + cy.request('/api/campaigns?order=asc&order_by=created_at').should((response) => { + const { data } = response.body; + expect(data.total).to.equal(lists.length * cTypes.length); + + let n = 0; + cTypes.forEach((c) => { + lists.forEach((l) => { + expect(data.results[n].content_type).to.equal(c); + expect(data.results[n].lists.map((ls) => ls.id)).to.deep.equal(l); + n++; + }); + }); + }); + }); + + it('Starts and cancels campaigns', () => { + for (let n = 1; n <= 2; n++) { + cy.get(`tbody tr:nth-child(${n}) [data-cy=btn-start]`).click(); + cy.get('.modal button.is-primary').click(); + cy.wait(250); + cy.get(`tbody tr:nth-child(${n}) td[data-label=Status] .tag.running`); + + if (n > 1) { + cy.get(`tbody tr:nth-child(${n}) [data-cy=btn-cancel]`).click(); + cy.get('.modal button.is-primary').click(); + cy.wait(250); + cy.get(`tbody tr:nth-child(${n}) td[data-label=Status] .tag.cancelled`); + } + } + }); + + it('Sorts campaigns', () => { + const asc = [5, 6, 7, 8, 9, 10]; + const desc = [10, 9, 8, 7, 6, 5]; + const cases = ['cy-name', 'cy-timestamp']; + + cases.forEach((c) => { + cy.sortTable(`thead th.${c}`, asc); + cy.wait(250); + cy.sortTable(`thead th.${c}`, desc); + cy.wait(250); + }); + }); +}); diff --git a/frontend/cypress/integration/dashboard.js b/frontend/cypress/integration/dashboard.js new file mode 100644 index 0000000..a19310d --- /dev/null +++ b/frontend/cypress/integration/dashboard.js @@ -0,0 +1,28 @@ +describe('Dashboard', () => { + it('Opens dashboard', () => { + cy.loginAndVisit('/'); + + // List counts. + cy.get('[data-cy=lists]') + .should('contain', '2 Lists') + .and('contain', '1 Public') + .and('contain', '1 Private') + .and('contain', '1 Single opt-in') + .and('contain', '1 Double opt-in'); + + // Campaign counts. + cy.get('[data-cy=campaigns]') + .should('contain', '1 Campaign') + .and('contain', '1 draft'); + + // Subscriber counts. + cy.get('[data-cy=subscribers]') + .should('contain', '2 Subscribers') + .and('contain', '0 Blocklisted') + .and('contain', '0 Orphans'); + + // Message count. + cy.get('[data-cy=messages]') + .should('contain', '0 Messages sent'); + }); +}); diff --git a/frontend/cypress/integration/forms.js b/frontend/cypress/integration/forms.js new file mode 100644 index 0000000..d401d51 --- /dev/null +++ b/frontend/cypress/integration/forms.js @@ -0,0 +1,36 @@ +describe('Forms', () => { + it('Opens forms page', () => { + cy.resetDB(); + cy.loginAndVisit('/lists/forms'); + }); + + it('Checks form URL', () => { + cy.get('a[data-cy=url]').contains('http://localhost:9000'); + }); + + it('Checks public lists', () => { + cy.get('ul[data-cy=lists] li') + .should('contain', 'Opt-in list') + .its('length') + .should('eq', 1); + + cy.get('[data-cy=form] pre').should('not.exist'); + }); + + it('Selects public list', () => { + // Click the list checkbox. + cy.get('ul[data-cy=lists] .checkbox').click(); + + // Make sure the
 form HTML has appeared.
+    cy.get('[data-cy=form] pre').then(($pre) => {
+      // Check that the ID of the list in the checkbox appears in the HTML.
+      cy.get('ul[data-cy=lists] input').then(($inp) => {
+        cy.wrap($pre).contains($inp.val());
+      });
+    });
+
+    // Click the list checkbox.
+    cy.get('ul[data-cy=lists] .checkbox').click();
+    cy.get('[data-cy=form] pre').should('not.exist');
+  });
+});
diff --git a/frontend/cypress/integration/import.js b/frontend/cypress/integration/import.js
new file mode 100644
index 0000000..b8b8d55
--- /dev/null
+++ b/frontend/cypress/integration/import.js
@@ -0,0 +1,50 @@
+
+describe('Import', () => {
+  it('Opens import page', () => {
+    cy.resetDB();
+    cy.loginAndVisit('/subscribers/import');
+  });
+
+  it('Imports subscribers', () => {
+    const cases = [
+      { mode: 'check-subscribe', status: 'enabled', count: 102 },
+      { mode: 'check-blocklist', status: 'blocklisted', count: 102 },
+    ];
+
+    cases.forEach((c) => {
+      cy.get(`[data-cy=${c.mode}] .check`).click();
+
+      if (c.status === 'enabled') {
+        cy.get('.list-selector input').click();
+        cy.get('.list-selector .autocomplete a').first().click();
+      }
+
+      cy.fixture('subs.csv').then((data) => {
+        cy.get('input[type="file"]').attachFile({
+          fileContent: data.toString(),
+          fileName: 'subs.csv',
+          mimeType: 'text/csv',
+        });
+      });
+
+      cy.get('button.is-primary').click();
+      cy.get('section.wrap .has-text-success');
+      cy.get('button.is-primary').click();
+      cy.wait(100);
+
+      // Verify that 100 (+2 default) subs are imported.
+      cy.loginAndVisit('/subscribers');
+      cy.wait(100);
+      cy.get('[data-cy=count]').then(($el) => {
+        cy.expect(parseInt($el.text().trim())).to.equal(c.count);
+      });
+
+      cy.get('tbody td[data-label=Status]').each(($el) => {
+        cy.wrap($el).find(`.tag.${c.status}`);
+      });
+
+      cy.loginAndVisit('/subscribers/import');
+      cy.wait(100);
+    });
+  });
+});
diff --git a/frontend/cypress/integration/lists.js b/frontend/cypress/integration/lists.js
new file mode 100644
index 0000000..a8210fb
--- /dev/null
+++ b/frontend/cypress/integration/lists.js
@@ -0,0 +1,130 @@
+describe('Lists', () => {
+  it('Opens lists page', () => {
+    cy.resetDB();
+    cy.loginAndVisit('/lists');
+  });
+
+
+  it('Counts subscribers in default lists', () => {
+    cy.get('tbody td[data-label=Subscribers]').contains('1');
+  });
+
+
+  it('Creates campaign for list', () => {
+    cy.get('tbody a[data-cy=btn-campaign]').first().click();
+    cy.location('pathname').should('contain', '/campaigns/new');
+    cy.get('.list-tags .tag').contains('Default list');
+
+    cy.clickMenu('lists', 'all-lists');
+  });
+
+
+  it('Creates opt-in campaign for list', () => {
+    cy.get('tbody a[data-cy=btn-send-optin-campaign]').click();
+    cy.get('.modal button.is-primary').click();
+    cy.location('pathname').should('contain', '/campaigns/2');
+
+    cy.clickMenu('lists', 'all-lists');
+  });
+
+
+  it('Checks individual subscribers in lists', () => {
+    const subs = [{ listID: 1, email: 'john@example.com' },
+      { listID: 2, email: 'anon@example.com' }];
+
+    // Click on each list on the lists page, go the the subscribers page
+    // for that list, and check the subscriber details.
+    subs.forEach((s, n) => {
+      cy.get('tbody td[data-label=Subscribers] a').eq(n).click();
+      cy.location('pathname').should('contain', `/subscribers/lists/${s.listID}`);
+      cy.get('tbody tr').its('length').should('eq', 1);
+      cy.get('tbody td[data-label="E-mail"]').contains(s.email);
+      cy.clickMenu('lists', 'all-lists');
+    });
+  });
+
+  it('Edits lists', () => {
+    // Open the edit popup and edit the default lists.
+    cy.get('[data-cy=btn-edit]').each(($el, n) => {
+      cy.wrap($el).click();
+      cy.get('input[name=name]').clear().type(`list-${n}`);
+      cy.get('select[name=type]').select('public');
+      cy.get('select[name=optin]').select('double');
+      cy.get('input[name=tags]').clear().type(`tag${n}`);
+      cy.get('button[type=submit]').click();
+    });
+    cy.wait(250);
+
+    // Confirm the edits.
+    cy.get('tbody tr').each(($el, n) => {
+      cy.wrap($el).find('td[data-label=Name]').contains(`list-${n}`);
+      cy.wrap($el).find('.tags')
+        .should('contain', 'test')
+        .and('contain', `tag${n}`);
+    });
+  });
+
+
+  it('Deletes lists', () => {
+    // Delete all visible lists.
+    cy.get('tbody tr').each(() => {
+      cy.get('tbody a[data-cy=btn-delete]').first().click();
+      cy.get('.modal button.is-primary').click();
+    });
+
+    // Confirm deletion.
+    cy.get('table tr.is-empty');
+  });
+
+
+  // Add new lists.
+  it('Adds new lists', () => {
+    // Open the list form and create lists of multiple type/optin combinations.
+    const types = ['private', 'public'];
+    const optin = ['single', 'double'];
+
+    let n = 0;
+    types.forEach((t) => {
+      optin.forEach((o) => {
+        const name = `list-${t}-${o}-${n}`;
+
+        cy.get('[data-cy=btn-new]').click();
+        cy.get('input[name=name]').type(name);
+        cy.get('select[name=type]').select(t);
+        cy.get('select[name=optin]').select(o);
+        cy.get('input[name=tags]').type(`tag${n}{enter}${t}{enter}${o}{enter}`);
+        cy.get('button[type=submit]').click();
+
+        // Confirm the addition by inspecting the newly created list row.
+        const tr = `tbody tr:nth-child(${n + 1})`;
+        cy.get(`${tr} td[data-label=Name]`).contains(name);
+        cy.get(`${tr} td[data-label=Type] [data-cy=type-${t}]`);
+        cy.get(`${tr} td[data-label=Type] [data-cy=optin-${o}]`);
+        cy.get(`${tr} .tags`)
+          .should('contain', `tag${n}`)
+          .and('contain', t)
+          .and('contain', o);
+
+        n++;
+      });
+    });
+  });
+
+
+  // Sort lists by clicking on various headers. At this point, there should be four
+  // lists with IDs = [3, 4, 5, 6]. Sort the items be columns and match them with
+  // the expected order of IDs.
+  it('Sorts lists', () => {
+    cy.sortTable('thead th.cy-name', [4, 3, 6, 5]);
+    cy.sortTable('thead th.cy-name', [5, 6, 3, 4]);
+
+    cy.sortTable('thead th.cy-type', [5, 6, 4, 3]);
+    cy.sortTable('thead th.cy-type', [4, 3, 5, 6]);
+
+    cy.sortTable('thead th.cy-created_at', [3, 4, 5, 6]);
+    cy.sortTable('thead th.cy-created_at', [6, 5, 4, 3]);
+
+    cy.sortTable('thead th.cy-updated_at', [3, 4, 5, 6]);
+    cy.sortTable('thead th.cy-updated_at', [6, 5, 4, 3]);
+  });
+});
diff --git a/frontend/cypress/integration/settings.js b/frontend/cypress/integration/settings.js
new file mode 100644
index 0000000..28de7bf
--- /dev/null
+++ b/frontend/cypress/integration/settings.js
@@ -0,0 +1,40 @@
+describe('Templates', () => {
+  it('Opens settings page', () => {
+    cy.resetDB();
+    cy.loginAndVisit('/settings');
+  });
+
+  it('Changes some settings', () => {
+    const rootURL = 'http://127.0.0.1:9000';
+    const faveURL = 'http://127.0.0.1:9000/public/static/logo.png';
+
+    cy.get('input[name="app.root_url"]').clear().type(rootURL);
+    cy.get('input[name="app.favicon_url"]').type(faveURL);
+    cy.get('.b-tabs nav a').eq(1).click();
+    cy.get('.tab-item:visible').find('.field').first()
+      .find('button')
+      .first()
+      .click();
+
+    // Enable / disable SMTP and delete one.
+    cy.get('.b-tabs nav a').eq(4).click();
+    cy.get('.tab-item:visible [data-cy=btn-enable-smtp]').eq(1).click();
+    cy.get('.tab-item:visible [data-cy=btn-delete-smtp]').first().click();
+    cy.get('.modal button.is-primary').click();
+
+    cy.get('[data-cy=btn-save]').click();
+
+    cy.wait(250);
+
+    // Verify the changes.
+    cy.request('/api/settings').should((response) => {
+      const { data } = response.body;
+      expect(data['app.root_url']).to.equal(rootURL);
+      expect(data['app.favicon_url']).to.equal(faveURL);
+      expect(data['app.concurrency']).to.equal(9);
+
+      expect(data.smtp.length).to.equal(1);
+      expect(data.smtp[0].enabled).to.equal(true);
+    });
+  });
+});
diff --git a/frontend/cypress/integration/subscribers.js b/frontend/cypress/integration/subscribers.js
new file mode 100644
index 0000000..aa74094
--- /dev/null
+++ b/frontend/cypress/integration/subscribers.js
@@ -0,0 +1,219 @@
+describe('Subscribers', () => {
+  it('Opens subscribers page', () => {
+    cy.resetDB();
+    cy.loginAndVisit('/subscribers');
+  });
+
+
+  it('Counts subscribers', () => {
+    cy.get('tbody td[data-label=Status]').its('length').should('eq', 2);
+  });
+
+
+  it('Searches subscribers', () => {
+    const cases = [
+      { value: 'john{enter}', count: 1, contains: 'john@example.com' },
+      { value: 'anon{enter}', count: 1, contains: 'anon@example.com' },
+      { value: '{enter}', count: 2, contains: null },
+    ];
+
+    cases.forEach((c) => {
+      cy.get('[data-cy=search]').clear().type(c.value);
+      cy.get('tbody td[data-label=Status]').its('length').should('eq', c.count);
+      if (c.contains) {
+        cy.get('tbody td[data-label=E-mail]').contains(c.contains);
+      }
+    });
+  });
+
+
+  it('Advanced searches subscribers', () => {
+    cy.get('[data-cy=btn-advanced-search]').click();
+
+    const cases = [
+      { value: 'subscribers.attribs->>\'city\'=\'Bengaluru\'', count: 2 },
+      { value: 'subscribers.attribs->>\'city\'=\'Bengaluru\' AND id=1', count: 1 },
+      { value: '(subscribers.attribs->>\'good\')::BOOLEAN = true AND name like \'Anon%\'', count: 1 },
+    ];
+
+    cases.forEach((c) => {
+      cy.get('[data-cy=query]').clear().type(c.value);
+      cy.get('[data-cy=btn-query]').click();
+      cy.get('tbody td[data-label=Status]').its('length').should('eq', c.count);
+    });
+
+    cy.get('[data-cy=btn-query-reset]').click();
+    cy.get('tbody td[data-label=Status]').its('length').should('eq', 2);
+  });
+
+
+  it('Does bulk subscriber list add and remove', () => {
+    const cases = [
+      // radio: action to perform, rows: table rows to select and perform on: [expected statuses of those rows after thea action]
+      { radio: 'check-list-add', lists: [0, 1], rows: { 0: ['unconfirmed', 'unconfirmed'] } },
+      { radio: 'check-list-unsubscribe', lists: [0, 1], rows: { 0: ['unsubscribed', 'unsubscribed'], 1: ['unsubscribed'] } },
+      { radio: 'check-list-remove', lists: [0, 1], rows: { 1: [] } },
+      { radio: 'check-list-add', lists: [0, 1], rows: { 0: ['unsubscribed', 'unsubscribed'], 1: ['unconfirmed', 'unconfirmed'] } },
+      { radio: 'check-list-remove', lists: [0], rows: { 0: ['unsubscribed'] } },
+      { radio: 'check-list-add', lists: [0], rows: { 0: ['unconfirmed', 'unsubscribed'] } },
+    ];
+
+
+    cases.forEach((c, n) => {
+      // Select one of the 2 subscriber in the table.
+      Object.keys(c.rows).forEach((r) => {
+        cy.get('tbody td.checkbox-cell .checkbox').eq(r).click();
+      });
+
+      // Open the 'manage lists' modal.
+      cy.get('[data-cy=btn-manage-lists]').click();
+
+      // Check both lists in the modal.
+      c.lists.forEach((l) => {
+        cy.get('.list-selector input').click();
+        cy.get('.list-selector .autocomplete a').first().click();
+      });
+
+      // Select the radio option in the modal.
+      cy.get(`[data-cy=${c.radio}] .check`).click();
+
+      // Save.
+      cy.get('.modal button.is-primary').click();
+
+      // Check the status of the lists on the subscriber.
+      Object.keys(c.rows).forEach((r) => {
+        cy.get('tbody td[data-label=E-mail]').eq(r).find('.tags').then(($el) => {
+          cy.wrap($el).find('.tag').should('have.length', c.rows[r].length);
+          c.rows[r].forEach((status, n) => {
+            // eg: .tag(n).unconfirmed
+            cy.wrap($el).find(`.tag:nth-child(${n + 1}).${status}`);
+          });
+        });
+      });
+    });
+  });
+
+  it('Resets subscribers page', () => {
+    cy.resetDB();
+    cy.loginAndVisit('/subscribers');
+  });
+
+
+  it('Edits subscribers', () => {
+    const status = ['enabled', 'blocklisted'];
+    const json = '{"string": "hello", "ints": [1,2,3], "null": null, "sub": {"bool": true}}';
+
+    // Collect values being edited on each sub to confirm the changes in the next step
+    // index by their ID shown in the modal.
+    const rows = {};
+
+    // Open the edit popup and edit the default lists.
+    cy.get('[data-cy=btn-edit]').each(($el, n) => {
+      const email = `email-${n}@email.com`;
+      const name = `name-${n}`;
+
+      // Open the edit modal.
+      cy.wrap($el).click();
+
+      // Get the ID from the header and proceed to fill the form.
+      let id = 0;
+      cy.get('[data-cy=id]').then(($el) => {
+        id = $el.text();
+
+        cy.get('input[name=email]').clear().type(email);
+        cy.get('input[name=name]').clear().type(name);
+        cy.get('select[name=status]').select(status[n]);
+        cy.get('.list-selector input').click();
+        cy.get('.list-selector .autocomplete a').first().click();
+        cy.get('textarea[name=attribs]').clear().type(json, { parseSpecialCharSequences: false, delay: 0 });
+        cy.get('.modal-card-foot button[type=submit]').click();
+
+        rows[id] = { email, name, status: status[n] };
+      });
+    });
+
+    // Confirm the edits on the table.
+    cy.get('tbody tr').each(($el) => {
+      cy.wrap($el).find('td[data-id]').invoke('attr', 'data-id').then((id) => {
+        cy.wrap($el).find('td[data-label=E-mail]').contains(rows[id].email);
+        cy.wrap($el).find('td[data-label=Name]').contains(rows[id].name);
+        cy.wrap($el).find('td[data-label=Status]').contains(rows[id].status, { matchCase: false });
+
+        // Both lists on the enabled sub should be 'unconfirmed' and the blocklisted one, 'unsubscribed.'
+        cy.wait(250);
+        cy.wrap($el).find(`.tags .${rows[id].status === 'enabled' ? 'unconfirmed' : 'unsubscribed'}`)
+          .its('length').should('eq', 2);
+        cy.wrap($el).find('td[data-label=Lists]').then((l) => {
+          cy.expect(parseInt(l.text().trim())).to.equal(rows[id].status === 'blocklisted' ? 0 : 2);
+        });
+      });
+    });
+  });
+
+  it('Deletes subscribers', () => {
+    // Delete all visible lists.
+    cy.get('tbody tr').each(() => {
+      cy.get('tbody a[data-cy=btn-delete]').first().click();
+      cy.get('.modal button.is-primary').click();
+    });
+
+    // Confirm deletion.
+    cy.get('table tr.is-empty');
+  });
+
+
+  it('Creates new subscribers', () => {
+    const statuses = ['enabled', 'blocklisted'];
+    const lists = [[1], [2], [1, 2]];
+    const json = '{"string": "hello", "ints": [1,2,3], "null": null, "sub": {"bool": true}}';
+
+
+    // Cycle through each status and each list ID combination and create subscribers.
+    const n = 0;
+    for (let n = 0; n < 6; n++) {
+      const email = `email-${n}@email.com`;
+      const name = `name-${n}`;
+      const status = statuses[(n + 1) % statuses.length];
+      const list = lists[(n + 1) % lists.length];
+
+      cy.get('[data-cy=btn-new]').click();
+      cy.get('input[name=email]').type(email);
+      cy.get('input[name=name]').type(name);
+      cy.get('select[name=status]').select(status);
+
+      list.forEach((l) => {
+        cy.get('.list-selector input').click();
+        cy.get('.list-selector .autocomplete a').first().click();
+      });
+      cy.get('textarea[name=attribs]').clear().type(json, { parseSpecialCharSequences: false, delay: 0 });
+      cy.get('.modal-card-foot button[type=submit]').click();
+
+      // Confirm the addition by inspecting the newly created list row,
+      // which is always the first row in the table.
+      cy.wait(250);
+      const tr = cy.get('tbody tr:nth-child(1)').then(($el) => {
+        cy.wrap($el).find('td[data-label=E-mail]').contains(email);
+        cy.wrap($el).find('td[data-label=Name]').contains(name);
+        cy.wrap($el).find('td[data-label=Status]').contains(status, { matchCase: false });
+        cy.wrap($el).find(`.tags .${status === 'enabled' ? 'unconfirmed' : 'unsubscribed'}`)
+          .its('length').should('eq', list.length);
+        cy.wrap($el).find('td[data-label=Lists]').then((l) => {
+          cy.expect(parseInt(l.text().trim())).to.equal(status === 'blocklisted' ? 0 : list.length);
+        });
+      });
+    }
+  });
+
+  it('Sorts subscribers', () => {
+    const asc = [3, 4, 5, 6, 7, 8];
+    const desc = [8, 7, 6, 5, 4, 3];
+    const cases = ['cy-status', 'cy-email', 'cy-name', 'cy-created_at', 'cy-updated_at'];
+
+    cases.forEach((c) => {
+      cy.sortTable(`thead th.${c}`, asc);
+      cy.wait(100);
+      cy.sortTable(`thead th.${c}`, desc);
+      cy.wait(100);
+    });
+  });
+});
diff --git a/frontend/cypress/integration/templates.js b/frontend/cypress/integration/templates.js
new file mode 100644
index 0000000..48cc5af
--- /dev/null
+++ b/frontend/cypress/integration/templates.js
@@ -0,0 +1,77 @@
+describe('Templates', () => {
+  it('Opens templates page', () => {
+    cy.resetDB();
+    cy.loginAndVisit('/campaigns/templates');
+  });
+
+
+  it('Counts default templates', () => {
+    cy.get('tbody td[data-label=Name]').should('have.length', 1);
+  });
+
+  it('Clones template', () => {
+    // Clone the campaign.
+    cy.get('[data-cy=btn-clone]').first().click();
+    cy.get('.modal input').clear().type('cloned').click();
+    cy.get('.modal button.is-primary').click();
+    cy.wait(250);
+
+    // Verify the newly created row.
+    cy.get('tbody td[data-label="Name"]').eq(1).contains('cloned');
+  });
+
+  it('Edits template', () => {
+    cy.get('tbody td.actions [data-cy=btn-edit]').first().click();
+    cy.wait(250);
+    cy.get('input[name=name]').clear().type('edited');
+    cy.get('textarea[name=body]').clear().type('test {{ template "content" . }}',
+      { parseSpecialCharSequences: false, delay: 0 });
+    cy.get('footer.modal-card-foot button.is-primary').click();
+    cy.wait(250);
+    cy.get('tbody td[data-label="Name"] a').contains('edited');
+  });
+
+
+  it('Previews templates', () => {
+    // Edited one sould have a bare body.
+    cy.get('tbody [data-cy=btn-preview').eq(0).click();
+    cy.wait(500);
+    cy.get('.modal-card-body iframe').iframe(() => {
+      cy.get('span').first().contains('test');
+      cy.get('p').first().contains('Hi there');
+    });
+    cy.get('footer.modal-card-foot button').click();
+
+    // Cloned one should have the full template.
+    cy.get('tbody [data-cy=btn-preview').eq(1).click();
+    cy.wait(500);
+    cy.get('.modal-card-body iframe').iframe(() => {
+      cy.get('.wrap p').first().contains('Hi there');
+      cy.get('.footer a').first().contains('Unsubscribe');
+    });
+    cy.get('footer.modal-card-foot button').click();
+  });
+
+  it('Sets default', () => {
+    cy.get('tbody td.actions').eq(1).find('[data-cy=btn-set-default]').click();
+    cy.get('.modal button.is-primary').click();
+
+    // The original default shouldn't have default and the new one should have.
+    cy.get('tbody td.actions').eq(0).then((el) => {
+      cy.wrap(el).find('[data-cy=btn-delete]').should('exist');
+      cy.wrap(el).find('[data-cy=btn-set-default]').should('exist');
+    });
+    cy.get('tbody td.actions').eq(1).then((el) => {
+      cy.wrap(el).find('[data-cy=btn-delete]').should('not.exist');
+      cy.wrap(el).find('[data-cy=btn-set-default]').should('not.exist');
+    });
+  });
+
+
+  it('Deletes template', () => {
+    cy.get('tbody td.actions [data-cy=btn-delete]').first().click();
+    cy.get('.modal button.is-primary').click();
+    cy.wait(250);
+    cy.get('tbody td.actions').should('have.length', 1);
+  });
+});
diff --git a/frontend/cypress/plugins/index.js b/frontend/cypress/plugins/index.js
new file mode 100644
index 0000000..aa9918d
--- /dev/null
+++ b/frontend/cypress/plugins/index.js
@@ -0,0 +1,21 @@
+/// 
+// ***********************************************************
+// This example plugins/index.js can be used to load plugins
+//
+// You can change the location of this file or turn off loading
+// the plugins file with the 'pluginsFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/plugins-guide
+// ***********************************************************
+
+// This function is called when a project is opened or re-opened (e.g. due to
+// the project's config changing)
+
+/**
+ * @type {Cypress.PluginConfig}
+ */
+module.exports = (on, config) => {
+  // `on` is used to hook into various events Cypress emits
+  // `config` is the resolved Cypress config
+}
diff --git a/frontend/cypress/support/commands.js b/frontend/cypress/support/commands.js
new file mode 100644
index 0000000..e8b3fbf
--- /dev/null
+++ b/frontend/cypress/support/commands.js
@@ -0,0 +1,42 @@
+import 'cypress-file-upload';
+
+Cypress.Commands.add('resetDB', () => {
+  // Although cypress clearly states that a webserver should not be run
+  // from within it, listmonk is killed, the DB reset, and run again
+  // in the background. If the DB is reset without restartin listmonk,
+  // the live Postgres connections in the app throw errors because the
+  // schema changes midway.
+  cy.exec(Cypress.env('server_init_command'));
+});
+
+// Takes a th class selector of a Buefy table, clicks it sorting the table,
+// then compares the values of [td.data-id] attri of all the rows in the
+// table against the given IDs, asserting the expected order of sort.
+Cypress.Commands.add('sortTable', (theadSelector, ordIDs) => {
+  cy.get(theadSelector).click();
+  cy.get('tbody td[data-id]').each(($el, index) => {
+    expect(ordIDs[index]).to.equal(parseInt($el.attr('data-id')));
+  });
+});
+
+Cypress.Commands.add('loginAndVisit', (url) => {
+  cy.visit(url, {
+    auth: {
+      username: Cypress.env('username'),
+      password: Cypress.env('password'),
+    },
+  });
+});
+
+Cypress.Commands.add('clickMenu', (...selectors) => {
+  selectors.forEach((s) => {
+    cy.get(`.menu a[data-cy="${s}"]`).click();
+  });
+});
+
+// https://www.nicknish.co/blog/cypress-targeting-elements-inside-iframes
+Cypress.Commands.add('iframe', { prevSubject: 'element' }, ($iframe, callback = () => {}) => cy
+    .wrap($iframe)
+    .should((iframe) => expect(iframe.contents().find('body')).to.exist)
+    .then((iframe) => cy.wrap(iframe.contents().find('body')))
+    .within({}, callback));
diff --git a/frontend/cypress/support/index.js b/frontend/cypress/support/index.js
new file mode 100644
index 0000000..02d3a1d
--- /dev/null
+++ b/frontend/cypress/support/index.js
@@ -0,0 +1,16 @@
+import './commands';
+
+beforeEach(() => {
+  cy.server({
+    ignore: (xhr) => {
+      // Ignore the webpack dev server calls that interfere in the tests
+      // when testing with `yarn serve`.
+      if (xhr.url.indexOf('sockjs-node/') > -1) {
+        return true;
+      }
+
+      // Return the default cypress whitelist filer.
+      return xhr.method === 'GET' && /\.(jsx?|html|css)(\?.*)?$/.test(xhr.url);
+    },
+  });
+});
diff --git a/frontend/cypress/support/reset.sh b/frontend/cypress/support/reset.sh
new file mode 100755
index 0000000..6bbf339
--- /dev/null
+++ b/frontend/cypress/support/reset.sh
@@ -0,0 +1,6 @@
+#!/bin/bash
+
+pkill -9 listmonk
+ cd ../
+./listmonk --install --yes
+./listmonk > /dev/null 2>/dev/null &
diff --git a/frontend/package.json b/frontend/package.json
index 6b921dc..6b6f3a6 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -15,7 +15,7 @@
     "codeflask": "^1.4.1",
     "core-js": "^3.6.5",
     "dayjs": "^1.8.28",
-    "elliptic": "^6.5.3",
+    "elliptic": "^6.5.4",
     "humps": "^2.0.1",
     "lodash": "^4.17.19",
     "node-forge": "^0.10.0",
@@ -40,6 +40,8 @@
     "@vue/cli-service": "~4.4.0",
     "@vue/eslint-config-airbnb": "^5.0.2",
     "babel-eslint": "^10.1.0",
+    "cypress": "^6.4.0",
+    "cypress-file-upload": "^5.0.2",
     "eslint": "^6.7.2",
     "eslint-plugin-import": "^2.20.2",
     "eslint-plugin-vue": "^6.2.2",
diff --git a/frontend/public/index.html b/frontend/public/index.html
index 8aa84f0..f0801d8 100644
--- a/frontend/public/index.html
+++ b/frontend/public/index.html
@@ -7,7 +7,6 @@
     
     
     <%= htmlWebpackPlugin.options.title %>
-