This is a test e-mail campaign. Your second name is {{ .Subscriber.LastName }} and you are from {{ .Subscriber.Attribs.city }}.
+Here is a tracked link.
+Use the link icon in the editor toolbar or when writing raw HTML or Markdown, + simply suffix @TrackLink to the end of a URL to turn it into a tracking link. Example:
+<a href="https://listmonk.app@TrackLink"></a>+
For help, refer to the documentation.
+ `, nil, "richtext", nil, + json.RawMessage("[]"), pq.StringArray{"test-campaign"}, emailMsgr, - 1, + campTplID, pq.Int64Array{1}, + false, + "welcome-to-listmonk", + archiveTplID, + `{"name": "Subscriber"}`, + nil, ); err != nil { lo.Fatalf("error creating sample campaign: %v", err) } - lo.Printf("setup complete") - lo.Printf(`run the program and access the dashboard at %s`, ko.MustString("app.address")) -} - -// installSchema executes the SQL schema and creates the necessary tables and types. -func installSchema(curVer string, db *sqlx.DB, fs stuffbin.FileSystem) error { - q, err := fs.Read("/schema.sql") - if err != nil { - return err - } - - if _, err := db.Exec(string(q)); err != nil { - return err - } - - // Insert the current migration version. - return recordMigrationVersion(curVer, db) } // recordMigrationVersion inserts the given version (of DB migration) into the @@ -189,14 +255,7 @@ func newConfigFile(path string) error { return fmt.Errorf("error reading sample config (is binary stuffed?): %v", err) } - // Generate a random admin password. - pwd, err := generateRandomString(16) - if err == nil { - b = regexp.MustCompile(`admin_password\s+?=\s+?(.*)`). - ReplaceAll(b, []byte(fmt.Sprintf(`admin_password = "%s"`, pwd))) - } - - return ioutil.WriteFile(path, b, 0644) + return os.WriteFile(path, b, 0644) } // checkSchema checks if the DB schema is installed. @@ -209,3 +268,21 @@ func checkSchema(db *sqlx.DB) (bool, error) { } return true, nil } + +func installUser(username, password string, q *models.Queries) { + consts := initConstants() + + // Super admin role. + perms := []string{} + for p := range consts.Permissions { + perms = append(perms, p) + } + + if _, err := q.CreateRole.Exec("Super Admin", "user", pq.Array(perms)); err != nil { + lo.Fatalf("error creating super admin role: %v", err) + } + + if _, err := q.CreateUser.Exec(username, true, password, username+"@listmonk", username, "user", 1, nil, "enabled"); err != nil { + lo.Fatalf("error creating superadmin user: %v", err) + } +} diff --git a/cmd/lists.go b/cmd/lists.go index d965b24ec..8b84da002 100644 --- a/cmd/lists.go +++ b/cmd/lists.go @@ -1,84 +1,89 @@ package main import ( - "fmt" "net/http" "strconv" + "strings" - "github.com/gofrs/uuid" + "github.com/knadh/listmonk/internal/auth" "github.com/knadh/listmonk/models" - "github.com/lib/pq" - - "github.com/labstack/echo" + "github.com/labstack/echo/v4" ) -type listsWrap struct { - Results []models.List `json:"results"` - - Total int `json:"total"` - PerPage int `json:"per_page"` - Page int `json:"page"` -} - -var ( - listQuerySortFields = []string{"name", "type", "subscriber_count", "created_at", "updated_at"} -) - -// handleGetLists handles retrieval of lists. +// handleGetLists retrieves lists with additional metadata like subscriber counts. func handleGetLists(c echo.Context) error { var ( - app = c.Get("app").(*App) - out listsWrap - - pg = getPagination(c.QueryParams(), 20) - orderBy = c.FormValue("order_by") - order = c.FormValue("order") - listID, _ = strconv.Atoi(c.Param("id")) - single = false + app = c.Get("app").(*App) + user = c.Get(auth.UserKey).(models.User) + pg = app.paginator.NewFromURL(c.Request().URL.Query()) + + query = strings.TrimSpace(c.FormValue("query")) + tags = c.QueryParams()["tag"] + orderBy = c.FormValue("order_by") + typ = c.FormValue("type") + optin = c.FormValue("optin") + order = c.FormValue("order") + minimal, _ = strconv.ParseBool(c.FormValue("minimal")) + + out models.PageResults ) - // Fetch one list. - if listID > 0 { - single = true + var ( + permittedIDs []int + getAll = false + ) + if _, ok := user.PermissionsMap[models.PermListGetAll]; ok { + getAll = true + } else { + permittedIDs = user.GetListIDs } - // Sort params. - if !strSliceContains(orderBy, listQuerySortFields) { - orderBy = "created_at" - } - if order != sortAsc && order != sortDesc { - order = sortAsc - } + // Minimal query simply returns the list of all lists without JOIN subscriber counts. This is fast. + if minimal { + res, err := app.core.GetLists("", getAll, permittedIDs) + if err != nil { + return err + } + if len(res) == 0 { + return c.JSON(http.StatusOK, okResp{[]struct{}{}}) + } - if err := db.Select(&out.Results, fmt.Sprintf(app.queries.QueryLists, orderBy, order), listID, pg.Offset, pg.Limit); err != nil { - app.log.Printf("error fetching lists: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.lists}", "error", pqErrMsg(err))) - } - if single && len(out.Results) == 0 { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.list}")) - } - if len(out.Results) == 0 { - return c.JSON(http.StatusOK, okResp{[]struct{}{}}) - } + // Meta. + out.Results = res + out.Total = len(res) + out.Page = 1 + out.PerPage = out.Total - // Replace null tags. - for i, v := range out.Results { - if v.Tags == nil { - out.Results[i].Tags = make(pq.StringArray, 0) - } + return c.JSON(http.StatusOK, okResp{out}) } - if single { - return c.JSON(http.StatusOK, okResp{out.Results[0]}) + // Full list query. + res, total, err := app.core.QueryLists(query, typ, optin, tags, orderBy, order, getAll, permittedIDs, pg.Offset, pg.Limit) + if err != nil { + return err } - // Meta. - out.Total = out.Results[0].Total + out.Query = query + out.Results = res + out.Total = total out.Page = pg.Page out.PerPage = pg.PerPage + + return c.JSON(http.StatusOK, okResp{out}) +} + +// handleGetList retrieves a single list by id. +func handleGetList(c echo.Context) error { + var ( + app = c.Get("app").(*App) + listID, _ = strconv.Atoi(c.Param("id")) + ) + + out, err := app.core.GetList(listID, "") + if err != nil { + return err + } + return c.JSON(http.StatusOK, okResp{out}) } @@ -86,44 +91,24 @@ func handleGetLists(c echo.Context) error { func handleCreateList(c echo.Context) error { var ( app = c.Get("app").(*App) - o = models.List{} + l = models.List{} ) - if err := c.Bind(&o); err != nil { + if err := c.Bind(&l); err != nil { return err } // Validate. - if !strHasLen(o.Name, 1, stdInputMaxLen) { + if !strHasLen(l.Name, 1, stdInputMaxLen) { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("lists.invalidName")) } - uu, err := uuid.NewV4() + out, err := app.core.CreateList(l) if err != nil { - app.log.Printf("error generating UUID: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorUUID", "error", err.Error())) - } - - // Insert and read ID. - var newID int - o.UUID = uu.String() - if err := app.queries.CreateList.Get(&newID, - o.UUID, - o.Name, - o.Type, - o.Optin, - pq.StringArray(normalizeTags(o.Tags))); err != nil { - app.log.Printf("error creating list: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorCreating", - "name", "{globals.terms.list}", "error", pqErrMsg(err))) + return err } - // Hand over to the GET handler to return the last insertion. - return handleGetLists(copyEchoCtx(c, map[string]string{ - "id": fmt.Sprintf("%d", newID), - })) + return c.JSON(http.StatusOK, okResp{out}) } // handleUpdateList handles list modification. @@ -138,26 +123,22 @@ func handleUpdateList(c echo.Context) error { } // Incoming params. - var o models.List - if err := c.Bind(&o); err != nil { + var l models.List + if err := c.Bind(&l); err != nil { return err } - res, err := app.queries.UpdateList.Exec(id, - o.Name, o.Type, o.Optin, pq.StringArray(normalizeTags(o.Tags))) - if err != nil { - app.log.Printf("error updating list: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorUpdating", - "name", "{globals.terms.list}", "error", pqErrMsg(err))) + // Validate. + if !strHasLen(l.Name, 1, stdInputMaxLen) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("lists.invalidName")) } - if n, _ := res.RowsAffected(); n == 0 { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.list}")) + out, err := app.core.UpdateList(id, l) + if err != nil { + return err } - return handleGetLists(c) + return c.JSON(http.StatusOK, okResp{out}) } // handleDeleteLists handles list deletion, either a single one (ID in the URI), or a list. @@ -165,7 +146,7 @@ func handleDeleteLists(c echo.Context) error { var ( app = c.Get("app").(*App) id, _ = strconv.ParseInt(c.Param("id"), 10, 64) - ids pq.Int64Array + ids []int ) if id < 1 && len(ids) == 0 { @@ -173,15 +154,46 @@ func handleDeleteLists(c echo.Context) error { } if id > 0 { - ids = append(ids, id) + ids = append(ids, int(id)) } - if _, err := app.queries.DeleteLists.Exec(ids); err != nil { - app.log.Printf("error deleting lists: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorDeleting", - "name", "{globals.terms.list}", "error", pqErrMsg(err))) + if err := app.core.DeleteLists(ids); err != nil { + return err } return c.JSON(http.StatusOK, okResp{true}) } + +// listPerm is a middleware for wrapping /list/* API calls that take a +// list :id param for validating the list ID against the user's list perms. +func listPerm(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + var ( + app = c.Get("app").(*App) + user = c.Get(auth.UserKey).(models.User) + id, _ = strconv.Atoi(c.Param("id")) + ) + + // Define permissions based on HTTP read/write. + var ( + permAll = models.PermListManageAll + perm = models.PermListManage + ) + if c.Request().Method == http.MethodGet { + permAll = models.PermListGetAll + perm = models.PermListGet + } + + // Check if the user has permissions for all lists or the specific list. + if _, ok := user.PermissionsMap[permAll]; ok { + return next(c) + } + if id > 0 { + if _, ok := user.ListPermissionsMap[id][perm]; ok { + return next(c) + } + } + + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.permissionDenied", "name", "list")) + } +} diff --git a/cmd/main.go b/cmd/main.go index 8c7274059..e7de40926 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -3,7 +3,6 @@ package main import ( "context" "fmt" - "html/template" "io" "log" "os" @@ -14,15 +13,20 @@ import ( "time" "github.com/jmoiron/sqlx" - "github.com/knadh/koanf" "github.com/knadh/koanf/providers/env" + "github.com/knadh/koanf/v2" + "github.com/knadh/listmonk/internal/auth" "github.com/knadh/listmonk/internal/bounce" "github.com/knadh/listmonk/internal/buflog" + "github.com/knadh/listmonk/internal/captcha" + "github.com/knadh/listmonk/internal/core" + "github.com/knadh/listmonk/internal/events" "github.com/knadh/listmonk/internal/i18n" "github.com/knadh/listmonk/internal/manager" "github.com/knadh/listmonk/internal/media" - "github.com/knadh/listmonk/internal/messenger" "github.com/knadh/listmonk/internal/subimporter" + "github.com/knadh/listmonk/models" + "github.com/knadh/paginator" "github.com/knadh/stuffbin" ) @@ -33,27 +37,37 @@ const ( // App contains the "global" components that are // passed around, especially through HTTP handlers. type App struct { - fs stuffbin.FileSystem - db *sqlx.DB - queries *Queries - constants *constants - manager *manager.Manager - importer *subimporter.Importer - messengers map[string]messenger.Messenger - media media.Store - i18n *i18n.I18n - bounce *bounce.Manager - notifTpls *template.Template - log *log.Logger - bufLog *buflog.BufLog + core *core.Core + fs stuffbin.FileSystem + db *sqlx.DB + queries *models.Queries + constants *constants + manager *manager.Manager + importer *subimporter.Importer + messengers []manager.Messenger + emailMessenger manager.Messenger + auth *auth.Auth + media media.Store + i18n *i18n.I18n + bounce *bounce.Manager + paginator *paginator.Paginator + captcha *captcha.Captcha + events *events.Events + notifTpls *notifTpls + about about + log *log.Logger + bufLog *buflog.BufLog // Channel for passing reload signals. - sigChan chan os.Signal + chReload chan os.Signal // Global variable that stores the state indicating that a restart is required // after a settings update. needsRestart bool + // First time installation with no user records in the DB. Needs user setup. + needsUserSetup bool + // Global state that stores data on an available remote update. update *AppUpdate sync.Mutex @@ -61,14 +75,15 @@ type App struct { var ( // Buffered log writer for storing N lines of log entries for the UI. - bufLog = buflog.New(5000) - lo = log.New(io.MultiWriter(os.Stdout, bufLog), "", - log.Ldate|log.Ltime|log.Lshortfile) + evStream = events.New() + bufLog = buflog.New(5000) + lo = log.New(io.MultiWriter(os.Stdout, bufLog, evStream.ErrWriter()), "", + log.Ldate|log.Ltime|log.Lmicroseconds|log.Lshortfile) ko = koanf.New(".") fs stuffbin.FileSystem db *sqlx.DB - queries *Queries + queries *models.Queries // Compile-time variables. buildString string @@ -78,7 +93,7 @@ var ( // are not embedded (in make dist), these paths are looked up. The default values before, when not // overridden by build flags, are relative to the CWD at runtime. appDir string = "." - frontendDir string = "frontend" + frontendDir string = "frontend/dist" ) func init() { @@ -141,11 +156,16 @@ func init() { // Before the queries are prepared, see if there are pending upgrades. checkUpgrade(db) - // Load the SQL queries from the filesystem. - _, queries := initQueries(queryFilePath, db, fs, true) + // Read the SQL queries from the queries file. + qMap := readQueries(queryFilePath, db, fs) // Load settings from DB. - initSettings(queries.GetSettings) + if q, ok := qMap["get-settings"]; ok { + initSettings(q.Query, db, ko) + } + + // Prepare queries. + queries = prepareQueries(qMap, db, ko) } func main() { @@ -156,40 +176,88 @@ func main() { db: db, constants: initConstants(), media: initMediaStore(), - messengers: make(map[string]messenger.Messenger), + messengers: []manager.Messenger{}, log: lo, bufLog: bufLog, + captcha: initCaptcha(), + events: evStream, + + paginator: paginator.New(paginator.Opt{ + DefaultPerPage: 20, + MaxPerPage: 50, + NumPageNums: 10, + PageParam: "page", + PerPageParam: "per_page", + AllowAll: true, + }), } // Load i18n language map. app.i18n = initI18n(app.constants.Lang, fs) + cOpt := &core.Opt{ + Constants: core.Constants{ + SendOptinConfirmation: app.constants.SendOptinConfirmation, + CacheSlowQueries: ko.Bool("app.cache_slow_queries"), + }, + Queries: queries, + DB: db, + I18n: app.i18n, + Log: lo, + } - _, app.queries = initQueries(queryFilePath, db, fs, true) + if err := ko.Unmarshal("bounce.actions", &cOpt.Constants.BounceActions); err != nil { + lo.Fatalf("error unmarshalling bounce config: %v", err) + } + + app.core = core.New(cOpt, &core.Hooks{ + SendOptinConfirmation: sendOptinConfirmationHook(app), + }) + + app.queries = queries app.manager = initCampaignManager(app.queries, app.constants, app) - app.importer = initImporter(app.queries, db, app) + app.importer = initImporter(app.queries, db, app.core, app) + + hasUsers, auth := initAuth(db.DB, ko, app.core) + app.auth = auth + // If there are are no users in the DB who can login, the app has to prompt + // for new user setup. + app.needsUserSetup = !hasUsers + app.notifTpls = initNotifTemplates("/email-templates/*.html", fs, app.i18n, app.constants) + initTxTemplates(app.manager, app) if ko.Bool("bounce.enabled") { app.bounce = initBounceManager(app) go app.bounce.Run() } - // Initialize the default SMTP (`email`) messenger. - app.messengers[emailMsgr] = initSMTPMessenger(app.manager) + // Initialize the SMTP messengers. + app.messengers = initSMTPMessengers() + for _, m := range app.messengers { + if m.Name() == emailMsgr { + app.emailMessenger = m + } + } // Initialize any additional postback messengers. - for _, m := range initPostbackMessengers(app.manager) { - app.messengers[m.Name()] = m - } + app.messengers = append(app.messengers, initPostbackMessengers()...) // Attach all messengers to the campaign manager. for _, m := range app.messengers { app.manager.AddMessenger(m) } + // Load system information. + app.about = initAbout(queries, db) + + // Start cronjobs. + if cOpt.Constants.CacheSlowQueries { + initCron(app.core) + } + // Start the campaign workers. The campaign batches (fetch from DB, push out // messages) get processed at the specified interval. - go app.manager.Run(time.Second * 5) + go app.manager.Run() // Start the app server. srv := initHTTPServer(app) @@ -202,11 +270,11 @@ func main() { // Wait for the reload signal with a callback to gracefully shut down resources. // The `wait` channel is passed to awaitReload to wait for the callback to finish // within N seconds, or do a force reload. - app.sigChan = make(chan os.Signal) - signal.Notify(app.sigChan, syscall.SIGHUP) + app.chReload = make(chan os.Signal) + signal.Notify(app.chReload, syscall.SIGHUP) closerWait := make(chan bool) - <-awaitReload(app.sigChan, closerWait, func() { + <-awaitReload(app.chReload, closerWait, func() { // Stop the HTTP server. ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() diff --git a/cmd/maintenance.go b/cmd/maintenance.go new file mode 100644 index 000000000..c11e3a854 --- /dev/null +++ b/cmd/maintenance.go @@ -0,0 +1,92 @@ +package main + +import ( + "net/http" + "time" + + "github.com/labstack/echo/v4" +) + +// handleGCSubscribers garbage collects (deletes) orphaned or blocklisted subscribers. +func handleGCSubscribers(c echo.Context) error { + var ( + app = c.Get("app").(*App) + typ = c.Param("type") + ) + + var ( + n int + err error + ) + + switch typ { + case "blocklisted": + n, err = app.core.DeleteBlocklistedSubscribers() + case "orphan": + n, err = app.core.DeleteOrphanSubscribers() + default: + err = echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData")) + } + + if err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{struct { + Count int `json:"count"` + }{n}}) +} + +// handleGCSubscriptions garbage collects (deletes) orphaned or blocklisted subscribers. +func handleGCSubscriptions(c echo.Context) error { + var ( + app = c.Get("app").(*App) + ) + + t, err := time.Parse(time.RFC3339, c.FormValue("before_date")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData")) + } + + n, err := app.core.DeleteUnconfirmedSubscriptions(t) + if err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{struct { + Count int `json:"count"` + }{n}}) +} + +// handleGCCampaignAnalytics garbage collects (deletes) campaign analytics. +func handleGCCampaignAnalytics(c echo.Context) error { + var ( + app = c.Get("app").(*App) + typ = c.Param("type") + ) + + t, err := time.Parse(time.RFC3339, c.FormValue("before_date")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData")) + } + + switch typ { + case "all": + if err := app.core.DeleteCampaignViews(t); err != nil { + return err + } + err = app.core.DeleteCampaignLinkClicks(t) + case "views": + err = app.core.DeleteCampaignViews(t) + case "clicks": + err = app.core.DeleteCampaignLinkClicks(t) + default: + err = echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData")) + } + + if err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{true}) +} diff --git a/cmd/manager_store.go b/cmd/manager_store.go index 5ff5e55d5..b1d67ef8c 100644 --- a/cmd/manager_store.go +++ b/cmd/manager_store.go @@ -1,27 +1,47 @@ package main import ( - "github.com/gofrs/uuid" + "net/http" + + "github.com/gofrs/uuid/v5" + "github.com/knadh/listmonk/internal/core" + "github.com/knadh/listmonk/internal/manager" + "github.com/knadh/listmonk/internal/media" "github.com/knadh/listmonk/models" "github.com/lib/pq" ) -// runnerDB implements runner.DataSource over the primary +// store implements DataSource over the primary // database. -type runnerDB struct { - queries *Queries +type store struct { + queries *models.Queries + core *core.Core + media media.Store + h *http.Client +} + +type runningCamp struct { + CampaignID int `db:"campaign_id"` + CampaignType string `db:"campaign_type"` + LastSubscriberID int `db:"last_subscriber_id"` + MaxSubscriberID int `db:"max_subscriber_id"` + ListID int `db:"list_id"` } -func newManagerStore(q *Queries) *runnerDB { - return &runnerDB{ +func newManagerStore(q *models.Queries, c *core.Core, m media.Store) *store { + return &store{ queries: q, + core: c, + media: m, } } -// NextCampaigns retrieves active campaigns ready to be processed. -func (r *runnerDB) NextCampaigns(excludeIDs []int64) ([]*models.Campaign, error) { +// NextCampaigns retrieves active campaigns ready to be processed excluding +// campaigns that are also being processed. Additionally, it takes a map of campaignID:sentCount +// of campaigns that are being processed and updates them in the DB. +func (s *store) NextCampaigns(currentIDs []int64, sentCounts []int64) ([]*models.Campaign, error) { var out []*models.Campaign - err := r.queries.NextCampaigns.Select(&out, pq.Int64Array(excludeIDs)) + err := s.queries.NextCampaigns.Select(&out, pq.Int64Array(currentIDs), pq.Int64Array(sentCounts)) return out, err } @@ -29,27 +49,66 @@ func (r *runnerDB) NextCampaigns(excludeIDs []int64) ([]*models.Campaign, error) // Since batches are processed sequentially, the retrieval is ordered by ID, // and every batch takes the last ID of the last batch and fetches the next // batch above that. -func (r *runnerDB) NextSubscribers(campID, limit int) ([]models.Subscriber, error) { +func (s *store) NextSubscribers(campID, limit int) ([]models.Subscriber, error) { + var camps []runningCamp + if err := s.queries.GetRunningCampaign.Select(&camps, campID); err != nil { + return nil, err + } + + var listIDs []int + for _, c := range camps { + listIDs = append(listIDs, c.ListID) + } + + if len(listIDs) == 0 { + return nil, nil + } + var out []models.Subscriber - err := r.queries.NextCampaignSubscribers.Select(&out, campID, limit) + err := s.queries.NextCampaignSubscribers.Select(&out, camps[0].CampaignID, camps[0].CampaignType, camps[0].LastSubscriberID, camps[0].MaxSubscriberID, pq.Array(listIDs), limit) return out, err } // GetCampaign fetches a campaign from the database. -func (r *runnerDB) GetCampaign(campID int) (*models.Campaign, error) { +func (s *store) GetCampaign(campID int) (*models.Campaign, error) { var out = &models.Campaign{} - err := r.queries.GetCampaign.Get(out, campID, nil) + err := s.queries.GetCampaign.Get(out, campID, nil, nil, "default") return out, err } // UpdateCampaignStatus updates a campaign's status. -func (r *runnerDB) UpdateCampaignStatus(campID int, status string) error { - _, err := r.queries.UpdateCampaignStatus.Exec(campID, status) +func (s *store) UpdateCampaignStatus(campID int, status string) error { + _, err := s.queries.UpdateCampaignStatus.Exec(campID, status) + return err +} + +// UpdateCampaignCounts updates a campaign's status. +func (s *store) UpdateCampaignCounts(campID int, toSend int, sent int, lastSubID int) error { + _, err := s.queries.UpdateCampaignCounts.Exec(campID, toSend, sent, lastSubID) return err } +// GetAttachment fetches a media attachment blob. +func (s *store) GetAttachment(mediaID int) (models.Attachment, error) { + m, err := s.core.GetMedia(mediaID, "", s.media) + if err != nil { + return models.Attachment{}, err + } + + b, err := s.media.GetBlob(m.URL) + if err != nil { + return models.Attachment{}, err + } + + return models.Attachment{ + Name: m.Filename, + Content: b, + Header: manager.MakeAttachmentHeader(m.Filename, "base64", m.ContentType), + }, nil +} + // CreateLink registers a URL with a UUID for tracking clicks and returns the UUID. -func (r *runnerDB) CreateLink(url string) (string, error) { +func (s *store) CreateLink(url string) (string, error) { // Create a new UUID for the URL. If the URL already exists in the DB // the UUID in the database is returned. uu, err := uuid.NewV4() @@ -58,7 +117,7 @@ func (r *runnerDB) CreateLink(url string) (string, error) { } var out string - if err := r.queries.CreateLink.Get(&out, uu, url); err != nil { + if err := s.queries.CreateLink.Get(&out, uu, url); err != nil { return "", err } @@ -66,13 +125,13 @@ func (r *runnerDB) CreateLink(url string) (string, error) { } // RecordBounce records a bounce event and returns the bounce count. -func (r *runnerDB) RecordBounce(b models.Bounce) (int64, int, error) { +func (s *store) RecordBounce(b models.Bounce) (int64, int, error) { var res = struct { SubscriberID int64 `db:"subscriber_id"` Num int `db:"num"` }{} - err := r.queries.UpdateCampaignStatus.Select(&res, + err := s.queries.UpdateCampaignStatus.Select(&res, b.SubscriberUUID, b.Email, b.CampaignUUID, @@ -83,12 +142,12 @@ func (r *runnerDB) RecordBounce(b models.Bounce) (int64, int, error) { return res.SubscriberID, res.Num, err } -func (r *runnerDB) BlocklistSubscriber(id int64) error { - _, err := r.queries.BlocklistSubscribers.Exec(pq.Int64Array{id}) +func (s *store) BlocklistSubscriber(id int64) error { + _, err := s.queries.BlocklistSubscribers.Exec(pq.Int64Array{id}) return err } -func (r *runnerDB) DeleteSubscriber(id int64) error { - _, err := r.queries.DeleteSubscribers.Exec(pq.Int64Array{id}) +func (s *store) DeleteSubscriber(id int64) error { + _, err := s.queries.DeleteSubscribers.Exec(pq.Int64Array{id}) return err } diff --git a/cmd/media.go b/cmd/media.go index b1c61bc05..1885d7aa1 100644 --- a/cmd/media.go +++ b/cmd/media.go @@ -6,22 +6,21 @@ import ( "net/http" "path/filepath" "strconv" + "strings" "github.com/disintegration/imaging" - "github.com/gofrs/uuid" - "github.com/knadh/listmonk/internal/media" - "github.com/labstack/echo" + "github.com/knadh/listmonk/models" + "github.com/labstack/echo/v4" ) const ( thumbPrefix = "thumb_" - thumbnailSize = 90 + thumbnailSize = 250 ) -// validMimes is the list of image types allowed to be uploaded. var ( - validMimes = []string{"image/jpg", "image/jpeg", "image/png", "image/gif"} - validExts = []string{".jpg", ".jpeg", ".png", ".gif"} + vectorExts = []string{"svg"} + imageExts = []string{"gif", "png", "jpg", "jpeg"} ) // handleUploadMedia handles media file uploads. @@ -36,23 +35,6 @@ func handleUploadMedia(c echo.Context) error { app.i18n.Ts("media.invalidFile", "error", err.Error())) } - // Validate file extension. - ext := filepath.Ext(file.Filename) - if ok := inArray(ext, validExts); !ok { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("media.unsupportedFileType", "type", ext)) - } - - // Validate file's mime. - typ := file.Header.Get("Content-type") - if ok := inArray(typ, validMimes); !ok { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("media.unsupportedFileType", "type", typ)) - } - - // Generate filename - fName := makeFilename(file.Filename) - // Read file contents in memory src, err := file.Open() if err != nil { @@ -61,76 +43,127 @@ func handleUploadMedia(c echo.Context) error { } defer src.Close() + var ( + // Naive check for content type and extension. + ext = strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Filename)), ".") + contentType = file.Header.Get("Content-Type") + ) + if !isASCII(file.Filename) { + return echo.NewHTTPError(http.StatusUnprocessableEntity, + app.i18n.Ts("media.invalidFileName", "name", file.Filename)) + } + + // Validate file extension. + if !inArray("*", app.constants.MediaUpload.Extensions) { + if ok := inArray(ext, app.constants.MediaUpload.Extensions); !ok { + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("media.unsupportedFileType", "type", ext)) + } + } + + // Sanitize filename. + fName := makeFilename(file.Filename) + + // Add a random suffix to the filename to ensure uniqueness. + suffix, _ := generateRandomString(6) + fName = appendSuffixToFilename(fName, suffix) + // Upload the file. - fName, err = app.media.Put(fName, typ, src) + fName, err = app.media.Put(fName, contentType, src) if err != nil { app.log.Printf("error uploading file: %v", err) - cleanUp = true return echo.NewHTTPError(http.StatusInternalServerError, app.i18n.Ts("media.errorUploading", "error", err.Error())) } + var ( + thumbfName = "" + width = 0 + height = 0 + ) defer func() { // If any of the subroutines in this function fail, // the uploaded image should be removed. if cleanUp { app.media.Delete(fName) - app.media.Delete(thumbPrefix + fName) + + if thumbfName != "" { + app.media.Delete(thumbfName) + } } }() - // Create thumbnail from file. - thumbFile, err := createThumbnail(file) - if err != nil { - cleanUp = true - app.log.Printf("error resizing image: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("media.errorResizing", "error", err.Error())) + // Create thumbnail from file for non-vector formats. + isImage := inArray(ext, imageExts) + if isImage { + thumbFile, w, h, err := processImage(file) + if err != nil { + cleanUp = true + app.log.Printf("error resizing image: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts("media.errorResizing", "error", err.Error())) + } + width = w + height = h + + // Upload thumbnail. + tf, err := app.media.Put(thumbPrefix+fName, contentType, thumbFile) + if err != nil { + cleanUp = true + app.log.Printf("error saving thumbnail: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts("media.errorSavingThumbnail", "error", err.Error())) + } + thumbfName = tf } - - // Upload thumbnail. - thumbfName, err := app.media.Put(thumbPrefix+fName, typ, thumbFile) - if err != nil { - cleanUp = true - app.log.Printf("error saving thumbnail: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("media.errorSavingThumbnail", "error", err.Error())) - } - - uu, err := uuid.NewV4() - if err != nil { - app.log.Printf("error generating UUID: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorUUID", "error", err.Error())) + if inArray(ext, vectorExts) { + thumbfName = fName } // Write to the DB. - if _, err := app.queries.InsertMedia.Exec(uu, fName, thumbfName, app.constants.MediaProvider); err != nil { + meta := models.JSON{} + if isImage { + meta = models.JSON{ + "width": width, + "height": height, + } + } + m, err := app.core.InsertMedia(fName, thumbfName, contentType, meta, app.constants.MediaUpload.Provider, app.media) + if err != nil { cleanUp = true - app.log.Printf("error inserting uploaded file to db: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorCreating", - "name", "{globals.terms.media}", "error", pqErrMsg(err))) + return err } - return c.JSON(http.StatusOK, okResp{true}) + return c.JSON(http.StatusOK, okResp{m}) } // handleGetMedia handles retrieval of uploaded media. func handleGetMedia(c echo.Context) error { var ( - app = c.Get("app").(*App) - out = []media.Media{} + app = c.Get("app").(*App) + pg = app.paginator.NewFromURL(c.Request().URL.Query()) + query = c.FormValue("query") + id, _ = strconv.Atoi(c.Param("id")) ) - if err := app.queries.GetMedia.Select(&out, app.constants.MediaProvider); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.media}", "error", pqErrMsg(err))) + // Fetch one list. + if id > 0 { + out, err := app.core.GetMedia(id, "", app.media) + if err != nil { + return err + } + return c.JSON(http.StatusOK, okResp{out}) } - for i := 0; i < len(out); i++ { - out[i].URL = app.media.Get(out[i].Filename) - out[i].ThumbURL = app.media.Get(out[i].Thumb) + res, total, err := app.core.QueryMedia(app.constants.MediaUpload.Provider, app.media, query, pg.Offset, pg.Limit) + if err != nil { + return err + } + + out := models.PageResults{ + Results: res, + Total: total, + Page: pg.Page, + PerPage: pg.PerPage, } return c.JSON(http.StatusOK, okResp{out}) @@ -147,29 +180,29 @@ func handleDeleteMedia(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } - var m media.Media - if err := app.queries.DeleteMedia.Get(&m, id); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorDeleting", - "name", "{globals.terms.media}", "error", pqErrMsg(err))) + fname, err := app.core.DeleteMedia(id) + if err != nil { + return err } - app.media.Delete(m.Filename) - app.media.Delete(thumbPrefix + m.Filename) + app.media.Delete(fname) + app.media.Delete(thumbPrefix + fname) + return c.JSON(http.StatusOK, okResp{true}) } -// createThumbnail reads the file object and returns a smaller image -func createThumbnail(file *multipart.FileHeader) (*bytes.Reader, error) { +// processImage reads the image file and returns thumbnail bytes and +// the original image's width, and height. +func processImage(file *multipart.FileHeader) (*bytes.Reader, int, int, error) { src, err := file.Open() if err != nil { - return nil, err + return nil, 0, 0, err } defer src.Close() img, err := imaging.Decode(src) if err != nil { - return nil, err + return nil, 0, 0, err } // Encode the image into a byte slice as PNG. @@ -178,7 +211,9 @@ func createThumbnail(file *multipart.FileHeader) (*bytes.Reader, error) { out bytes.Buffer ) if err := imaging.Encode(&out, thumb, imaging.PNG); err != nil { - return nil, err + return nil, 0, 0, err } - return bytes.NewReader(out.Bytes()), nil + + b := img.Bounds().Max + return bytes.NewReader(out.Bytes()), b.X, b.Y, nil } diff --git a/cmd/notifications.go b/cmd/notifications.go index 3fd0406c1..eece80ed2 100644 --- a/cmd/notifications.go +++ b/cmd/notifications.go @@ -2,8 +2,11 @@ package main import ( "bytes" + "net/textproto" + "regexp" + "strings" - "github.com/knadh/listmonk/internal/manager" + "github.com/knadh/listmonk/models" ) const ( @@ -13,6 +16,10 @@ const ( notifSubscriberData = "subscriber-data" ) +var ( + reTitle = regexp.MustCompile(`(?s)Hi there
+Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis et elit ac elit sollicitudin condimentum non a magna. + Sed tempor mauris in facilisis vehicula. Aenean nisl urna, accumsan ac tincidunt vitae, interdum cursus massa. + Interdum et malesuada fames ac ante ipsum primis in faucibus. Aliquam varius turpis et turpis lacinia placerat. + Aenean id ligula a orci lacinia blandit at eu felis. Phasellus vel lobortis lacus. Suspendisse leo elit, luctus sed + erat ut, venenatis fermentum ipsum. Donec bibendum neque quis.
+ +Nam luctus dui non placerat mattis. Morbi non accumsan orci, vel interdum urna. Duis faucibus id nunc ut euismod. + Curabitur et eros id erat feugiat fringilla in eget neque. Aliquam accumsan cursus eros sed faucibus.
+ +Here is a link to listmonk.
+``` + +______________________________________________________________________ + +#### POST /api/templates + +Create a template. + +##### Parameters + +| Name | Type | Required | Description | +|:--------|:----------|:---------|:----------------------------------------------| +| name | string | Yes | Name of the template | +| type | string | Yes | Type of the template (`campaign` or `tx`) | +| subject | string | | Subject line for the template (only for `tx`) | +| body | string | Yes | HTML body of the template | + +##### Example Request + +```shell +curl -u "api_user:token" -X POST 'http://localhost:9000/api/templates' \ +-H 'Content-Type: application/json' \ +-d '{ + "name": "New template", + "type": "campaign", + "subject": "Your Weekly Newsletter", + "body": "Content goes here
" +}' +``` + +##### Example Response + +```json +{ + "data": [ + { + "id": 1, + "created_at": "2020-03-14T17:36:41.288578+01:00", + "updated_at": "2020-03-14T17:36:41.288578+01:00", + "name": "Default template", + "body": "{{ template \"content\" . }}", + "type": "campaign", + "is_default": true + } + ] +} +``` + +______________________________________________________________________ + +#### PUT /api/templates/{template_id} + +Update a template. + +> Refer to parameters from [POST /api/templates](#post-apitemplates) + +______________________________________________________________________ + +#### PUT /api/templates/{template_id}/default + +Set a template as the default. + +##### Parameters + +| Name | Type | Required | Description | +|:------------|:----------|:---------|:-------------------------------------| +| template_id | number | Yes | ID of the template to set as default | + +##### Example Request + +```shell +curl -u "api_user:token" -X PUT 'http://localhost:9000/api/templates/1/default' +``` + +##### Example Response + +```json +{ + "data": { + "id": 1, + "created_at": "2020-03-14T17:36:41.288578+01:00", + "updated_at": "2020-03-14T17:36:41.288578+01:00", + "name": "Default template", + "body": "{{ template \"content\" . }}", + "type": "campaign", + "is_default": true + } +} +``` + +______________________________________________________________________ + +#### DELETE /api/templates/{template_id} + +Delete a template. + +##### Parameters + +| Name | Type | Required | Description | +|:------------|:----------|:---------|:-----------------------------| +| template_id | number | Yes | ID of the template to delete | + +##### Example Request + +```shell +curl -u "api_user:token" -X DELETE 'http://localhost:9000/api/templates/35' +``` + +##### Example Response + +```json +{ + "data": true +} +``` diff --git a/docs/docs/content/apis/transactional.md b/docs/docs/content/apis/transactional.md new file mode 100644 index 000000000..80207ec73 --- /dev/null +++ b/docs/docs/content/apis/transactional.md @@ -0,0 +1,65 @@ +# API / Transactional + +| Method | Endpoint | Description | +|:-------|:---------|:-------------------------------| +| POST | /api/tx | Send transactional messages | + +______________________________________________________________________ + +#### POST /api/tx + +Allows sending transactional messages to one or more subscribers via a preconfigured transactional template. + +##### Parameters + +| Name | Type | Required | Description | +|:------------------|:----------|:---------|:---------------------------------------------------------------------------| +| subscriber_email | string | | Email of the subscriber. Can substitute with `subscriber_id`. | +| subscriber_id | number | | Subscriber's ID can substitute with `subscriber_email`. | +| subscriber_emails | string\[\] | | Multiple subscriber emails as alternative to `subscriber_email`. | +| subscriber_ids | number\[\] | | Multiple subscriber IDs as an alternative to `subscriber_id`. | +| template_id | number | Yes | ID of the transactional template to be used for the message. | +| from_email | string | | Optional sender email. | +| data | JSON | | Optional nested JSON map. Available in the template as `{{ .Tx.Data.* }}`. | +| headers | JSON\[\] | | Optional array of email headers. | +| messenger | string | | Messenger to send the message. Default is `email`. | +| content_type | string | | Email format options include `html`, `markdown`, and `plain`. | + +##### Example + +```shell +curl -u "api_user:token" "http://localhost:9000/api/tx" -X POST \ + -H 'Content-Type: application/json; charset=utf-8' \ + --data-binary @- << EOF + { + "subscriber_email": "user@test.com", + "template_id": 2, + "data": {"order_id": "1234", "date": "2022-07-30", "items": [1, 2, 3]}, + "content_type": "html" + } +EOF +``` + +##### Example response + +```json +{ + "data": true +} +``` + +______________________________________________________________________ + +#### File Attachments + +To include file attachments in a transactional message, use the `multipart/form-data` Content-Type. Use `data` param for the parameters described above as a JSON object. Include any number of attachments via the `file` param. + +```shell +curl -u "api_user:token" "http://localhost:9000/api/tx" -X POST \ +-F 'data=\"{ + \"subscriber_email\": \"user@test.com\", + \"template_id\": 4 +}"' \ +-F 'file=@"/path/to/attachment.pdf"' \ +-F 'file=@"/path/to/attachment2.pdf"' +``` diff --git a/docs/docs/content/archives.md b/docs/docs/content/archives.md new file mode 100644 index 000000000..2c2b639b2 --- /dev/null +++ b/docs/docs/content/archives.md @@ -0,0 +1,32 @@ +# Archives + +A global public archive is maintained on the public web interface. It can be +enabled under Settings -> Settings -> General -> Enable public mailing list +archive. + +To make a campaign available in the public archive (provided it has been +enabled in the settings as described above), enable the option +'Publish to public archive' under Campaigns -> Create new -> Archive. + +When using template variables that depend on subscriber data (such as any +template variable referencing `.Subscriber`), such data must be supplied +as 'Campaign metadata', which is a JSON object that will be used in place +of `.Subscriber` when rendering the archive template and content. + +When individual subscriber tracking is enabled, TrackLink requires that a UUID +of an existing user is provided as part of the campaign metadata. Any clicks on +a TrackLink from the archived campaign will be counted towards that subscriber. + +As an example: + +```json +{ + "UUID": "5a837423-a186-5623-9a87-82691cbe3631", + "email": "example@example.com", + "name": "Reader", + "attribs": {} +} +``` + +![Archive campaign](images/archived-campaign-metadata.png) + diff --git a/docs/docs/content/bounces.md b/docs/docs/content/bounces.md new file mode 100644 index 000000000..84d76418e --- /dev/null +++ b/docs/docs/content/bounces.md @@ -0,0 +1,106 @@ +# Bounce processing + +Enable bounce processing in Settings -> Bounces. POP3 bounce scanning and APIs only become available once the setting is enabled. + +## POP3 bounce mailbox +Configure the bounce mailbox in Settings -> Bounces. Either the "From" e-mail that is set on a campaign (or in settings) should have a POP3 mailbox behind it to receive bounce e-mails, or you should configure a dedicated POP3 mailbox and add that address as the `Return-Path` (envelope sender) header in Settings -> SMTP -> Custom headers box. For example: + +``` +[ + {"Return-Path": "your-bounce-inbox@site.com"} +] + +``` + +Some mail servers may also return the bounce to the `Reply-To` address, which can also be added to the header settings. + +## Webhook API +The bounce webhook API can be used to record bounce events with custom scripting. This could be by reading a mailbox, a database, or mail server logs. + +| Method | Endpoint | Description | +| ------ | ---------------- | ---------------------- | +| `POST` | /webhooks/bounce | Record a bounce event. | + + +| Name | Type | Required | Description | +| ----------------| --------- | -----------| ------------------------------------------------------------------------------------ | +| subscriber_uuid | string | | The UUID of the subscriber. Either this or `email` is required. | +| email | string | | The e-mail of the subscriber. Either this or `subscriber_uuid` is required. | +| campaign_uuid | string | | UUID of the campaign for which the bounce happened. | +| source | string | Yes | A string indicating the source, eg: `api`, `my_script` etc. | +| type | string | Yes | `hard` or `soft` bounce. Currently, this has no effect on how the bounce is treated. | +| meta | string | | An optional escaped JSON string with arbitrary metadata about the bounce event. | + + +```shell +curl -u 'api_username:access_token' -X POST 'http://localhost:9000/webhooks/bounce' \ + -H "Content-Type: application/json" \ + --data '{"email": "user1@mail.com", "campaign_uuid": "9f86b50d-5711-41c8-ab03-bc91c43d711b", "source": "api", "type": "hard", "meta": "{\"additional\": \"info\"}}' + +``` + +## External webhooks +listmonk supports receiving bounce webhook events from the following SMTP providers. + +| Endpoint | Description | More info | +|:--------------------------------------------------------------|:---------------------------------------|:----------------------------------------------------------------------------------------------------------------------| +| `https://listmonk.yoursite.com/webhooks/service/ses` | Amazon (AWS) SES | See below | +| `https://listmonk.yoursite.com/webhooks/service/sendgrid` | Sendgrid / Twilio Signed event webhook | [More info](https://docs.sendgrid.com/for-developers/tracking-events/getting-started-event-webhook-security-features) | +| `https://listmonk.yoursite.com/webhooks/service/postmark` | Postmark webhook | [More info](https://postmarkapp.com/developer/webhooks/webhooks-overview) | +| `https://listmonk.yoursite.com/webhooks/service/forwardemail` | Forward Email webhook | [More info](https://forwardemail.net/en/faq#do-you-support-bounce-webhooks) | + +## Amazon Simple Email Service (SES) + +If using SES as your SMTP provider, automatic bounce processing is the recommended way to maintain your [sender reputation](https://docs.aws.amazon.com/ses/latest/dg/monitor-sender-reputation.html). The settings below are based on Amazon's [recommendations](https://docs.aws.amazon.com/ses/latest/dg/send-email-concepts-deliverability.html). Please note that your sending domain must be verified in SES before proceeding. + +1. In listmonk settings, go to the "Bounces" tab and configure the following: + - Enable bounce processing: `Enabled` + - Soft: + - Bounce count: `2` + - Action: `None` + - Hard: + - Bounce count: `1` + - Action: `Blocklist` + - Complaint: + - Bounce count: `1` + - Action: `Blocklist` + - Enable bounce webhooks: `Enabled` + - Enable SES: `Enabled` +2. In the AWS console, go to [Simple Notification Service](https://console.aws.amazon.com/sns/) and create a new topic with the following settings: + - Type: `Standard` + - Name: `ses-bounces` (or any other name) +3. Create a new subscription to that topic with the following settings: + - Protocol: `HTTPS` + - Endpoint: `https://listmonk.yoursite.com/webhooks/service/ses` + - Enable raw message delivery: `Disabled` (unchecked) +4. SES will then make a request to your listmonk instance to confirm the subscription. After a page refresh, the subscription should have a status of "Confirmed". If not, your endpoint may be incorrect or not publicly accessible. +5. In the AWS console, go to [Simple Email Service](https://console.aws.amazon.com/ses/) and click "Verified identities" in the left sidebar. +6. Click your domain and go to the "Notifications" tab. +7. Next to "Feedback notifications", click "Edit". +8. For both "Bounce feedback" and "Complaint feedback", use the following settings: + - SNS topic: `ses-bounces` (or whatever you named it) + - Include original email headers: `Enabled` (checked) +9. Repeat steps 6-8 for any `Email address` identities you send from using listmonk +10. Bounce processing should now be working. You can test it with [SES simulator addresses](https://docs.aws.amazon.com/ses/latest/dg/send-an-email-from-console.html#send-email-simulator). Add them as subscribers, send them campaign previews, and ensure that the appropriate action was taken after the configured bounce count was reached. + - Soft bounce: `ooto@simulator.amazonses.com` + - Hard bounce: `bounce@simulator.amazonses.com` + - Complaint: `complaint@simulator.amazonses.com` +11. You can optionally [disable email feedback forwarding](https://docs.aws.amazon.com/ses/latest/dg/monitor-sending-activity-using-notifications-email.html#monitor-sending-activity-using-notifications-email-disabling). + +## Exporting bounces + +Bounces can be exported via the JSON API: +```shell +curl -u 'username:passsword' 'http://localhost:9000/api/bounces' +``` + +Or by querying the database directly: +```sql +SELECT bounces.created_at, + bounces.subscriber_id, + subscribers.uuid AS subscriber_uuid, + subscribers.email AS email +FROM bounces +LEFT JOIN subscribers ON (subscribers.id = bounces.subscriber_id) +ORDER BY bounces.created_at DESC LIMIT 1000; +``` diff --git a/docs/docs/content/concepts.md b/docs/docs/content/concepts.md new file mode 100644 index 000000000..2064962cb --- /dev/null +++ b/docs/docs/content/concepts.md @@ -0,0 +1,72 @@ +# Concepts + +## Subscriber + +A subscriber is a recipient identified by an e-mail address and name. Subscribers receive e-mails that are sent from listmonk. A subscriber can be added to any number of lists. Subscribers who are not a part of any lists are considered *orphan* records. + +### Attributes + +Attributes are arbitrary properties attached to a subscriber in addition to their e-mail and name. They are represented as a JSON map. It is not necessary for all subscribers to have the same attributes. Subscribers can be [queried and segmented](querying-and-segmentation.md) into lists based on their attributes, and the attributes can be inserted into the e-mails sent to them. For example: + +```json +{ + "city": "Bengaluru", + "likes_tea": true, + "spoken_languages": ["English", "Malayalam"], + "projects": 3, + "stack": { + "frameworks": ["echo", "go"], + "languages": ["go", "python"], + "preferred_language": "go" + } +} +``` + +### Subscription statuses + +A subscriber can be added to one or more lists, and each such relationship can have one of these statuses. + +| Status | Description | +| ------------- | --------------------------------------------------------------------------------- | +| `unconfirmed` | The subscriber was added to the list directly without their explicit confirmation. Nonetheless, the subscriber will receive campaign messages sent to single optin campaigns. | +| `confirmed` | The subscriber confirmed their subscription by clicking on 'accept' in the confirmation e-mail. Only confirmed subscribers in opt-in lists will receive campaign messages send to the list. | +| `unsubscribed` | The subscriber is unsubscribed from the list and will not receive any campaign messages sent to the list. + + +### Segmentation + +Segmentation is the process of filtering a large list of subscribers into a smaller group based on arbitrary conditions, primarily based on their attributes. For instance, if an e-mail needs to be sent subscribers who live in a particular city, given their city is described in their attributes, it's possible to quickly filter them out into a new list and e-mail them. [Learn more](querying-and-segmentation.md). + +## List + +A list (or a _mailing list_) is a collection of subscribers grouped under a name, for instance, _clients_. Lists are used to organise subscribers and send e-mails to specific groups. A list can be single optin or double optin. Subscribers added to double optin lists have to explicitly accept the subscription by clicking on the confirmation e-mail they receive. Until then, they do not receive campaign messages. + +## Campaign + +A campaign is an e-mail (or any other kind of messages) that is sent to one or more lists. + + +## Transactional message + +A transactional message is an arbitrary message sent to a subscriber using the transactional message API. For example a welcome e-mail on signing up to a service; an order confirmation e-mail on purchasing an item; a password reset e-mail when a user initiates an online account recovery process. + + +## Template + +A template is a re-usable HTML design that can be used across campaigns and when sending arbitrary transactional messages. Most commonly, templates have standard header and footer areas with logos and branding elements, where campaign content is inserted in the middle. listmonk supports [Go template](https://gowebexamples.com/templates/) expressions that lets you create powerful, dynamic HTML templates. [Learn more](templating.md). + +## Messenger + +listmonk supports multiple custom messaging backends in additional to the default SMTP e-mail backend, enabling not just e-mail campaigns, but arbitrary message campaigns such as SMS, FCM notifications etc. A *Messenger* is a web service that accepts a campaign message pushed to it as a JSON request, which the service can in turn broadcast as SMS, FCM etc. [Learn more](messengers.md). + +## Tracking pixel + +The tracking pixel is a tiny, invisible image that is inserted into an e-mail body to track e-mail views. This allows measuring the read rate of e-mails. While this is exceedingly common in e-mail campaigns, it carries privacy implications and should be used in compliance with rules and regulations such as GDPR. It is possible to track reads anonymously without associating an e-mail read to a subscriber. + +## Click tracking + +It is possible to track the clicks on every link that is sent in an e-mail. This allows measuring the clickthrough rates of links in e-mails. While this is exceedingly common in e-mail campaigns, it carries privacy implications and should be used in compliance with rules and regulations such as GDPR. It is possible to track link clicks anonymously without associating an e-mail read to a subscriber. + +## Bounce + +A bounce occurs when an e-mail that is sent to a recipient "bounces" back for one of many reasons including the recipient address being invalid, their mailbox being full, or the recipient's e-mail service provider marking the e-mail as spam. listmonk can automatically process such bounce e-mails that land in a configured POP mailbox, or via APIs of SMTP e-mail providers such as AWS SES and Sengrid. Based on settings, subscribers returning bounced e-mails can either be blocklisted or deleted automatically. [Learn more](bounces.md). diff --git a/docs/docs/content/configuration.md b/docs/docs/content/configuration.md new file mode 100644 index 000000000..11e7244fd --- /dev/null +++ b/docs/docs/content/configuration.md @@ -0,0 +1,150 @@ +# Configuration + +### TOML Configuration file +One or more TOML files can be read by passing `--config config.toml` multiple times. Apart from a few low level configuration variables and the database configuration, all other settings can be managed from the `Settings` dashboard on the admin UI. + +To generate a new sample configuration file, run `--listmonk --new-config` + +### Environment variables +Variables in config.toml can also be provided as environment variables prefixed by `LISTMONK_` with periods replaced by `__` (double underscore). To start listmonk purely with environment variables without a configuration file, set the environment variables and pass the config flag as `--config=""`. + +Example: + +| **Environment variable** | Example value | +| ------------------------------ | -------------- | +| `LISTMONK_app__address` | "0.0.0.0:9000" | +| `LISTMONK_db__host` | db | +| `LISTMONK_db__port` | 9432 | +| `LISTMONK_db__user` | listmonk | +| `LISTMONK_db__password` | listmonk | +| `LISTMONK_db__database` | listmonk | +| `LISTMONK_db__ssl_mode` | disable | + + +### Customizing system templates +See [system templates](templating.md#system-templates). + + +### HTTP routes +When configuring auth proxies and web application firewalls, use this table. + +#### Private admin endpoints. + +| Methods | Route | Description | +| ------- | ------------------ | ----------------------- | +| `*` | `/api/*` | Admin APIs | +| `GET` | `/admin/*` | Admin UI and HTML pages | +| `POST` | `/webhooks/bounce` | Admin bounce webhook | + + +#### Public endpoints to expose to the internet. + +| Methods | Route | Description | +| ----------- | --------------------- | --------------------------------------------- | +| `GET, POST` | `/subscription/*` | HTML subscription pages | +| `GET, ` | `/link/*` | Tracked link redirection | +| `GET` | `/campaign/*` | Pixel tracking image | +| `GET` | `/public/*` | Static files for HTML subscription pages | +| `POST` | `/webhooks/service/*` | Bounce webhook endpoints for AWS and Sendgrid | +| `GET` | `/uploads/*` | The file upload path configured in media settings | + + +## Media uploads + +#### Using filesystem + +When configuring `docker` volume mounts for using filesystem media uploads, you can follow either of two approaches. [The second option may be necessary if](https://github.com/knadh/listmonk/issues/1169#issuecomment-1674475945) your setup requires you to use `sudo` for docker commands. + +After making any changes you will need to run `sudo docker compose stop ; sudo docker compose up`. + +And under `https://listmonk.mysite.com/admin/settings` you put `/listmonk/uploads`. + +#### Using volumes + +Using `docker volumes`, you can specify the name of volume and destination for the files to be uploaded inside the container. + + +```yml +app: + volumes: + - type: volume + source: listmonk-uploads + target: /listmonk/uploads + +volumes: + listmonk-uploads: +``` + +!!! note + + This volume is managed by `docker` itself, and you can see find the host path with `docker volume inspect listmonk_listmonk-uploads`. + +#### Using bind mounts + +```yml + app: + volumes: + - ./path/on/your/host/:/path/inside/container +``` +Eg: +```yml + app: + volumes: + - ./data/uploads:/listmonk/uploads +``` +The files will be available inside `/data/uploads` directory on the host machine. + +To use the default `uploads` folder: +```yml + app: + volumes: + - ./uploads:/listmonk/uploads +``` + +## Logs + +### Docker + +https://docs.docker.com/engine/reference/commandline/logs/ +``` +sudo docker logs -f +sudo docker logs listmonk_app -t +sudo docker logs listmonk_db -t +sudo docker logs --help +``` +Container info: `sudo docker inspect listmonk_listmonk` + +Docker logs to `/dev/stdout` and `/dev/stderr`. The logs are collected by the docker daemon and stored in your node's host path (by default). The same can be configured (/etc/docker/daemon.json) in your docker daemon settings to setup other logging drivers, logrotate policy and more, which you can read about [here](https://docs.docker.com/config/containers/logging/configure/). + +### Binary + +listmonk logs to `stdout`, which is usually not saved to any file. To save listmonk logs to a file use `./listmonk > listmonk.log`. + +Settings -> Logs in admin shows the last 1000 lines of the standard log output but gets erased when listmonk is restarted. + +For the [service file](https://github.com/knadh/listmonk/blob/master/listmonk%40.service), you can use `ExecStart=/bin/bash -ce "exec /usr/bin/listmonk --config /etc/listmonk/config.toml --static-dir /etc/listmonk/static >>/etc/listmonk/listmonk.log 2>&1"` to create a log file that persists after restarts. [More info](https://github.com/knadh/listmonk/issues/1462#issuecomment-1868501606). + + +## Time zone + +To change listmonk's time zone (logs, etc.) edit `docker-compose.yml`: +``` +environment: + - TZ=Etc/UTC +``` +with any Timezone listed [here](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). Then run `sudo docker-compose stop ; sudo docker-compose up` after making changes. + +## SMTP + +### Retries +The `Settings -> SMTP -> Retries` denotes the number of times a message that fails at the moment of sending is retried silently using different connections from the SMTP pool. The messages that fail even after retries are the ones that are logged as errors and ignored. + +## SMTP ports +Some server hosts block outgoing SMTP ports (25, 465). You may have to contact your host to unblock them before being able to send e-mails. Eg: [Hetzner](https://docs.hetzner.com/cloud/servers/faq/#why-can-i-not-send-any-mails-from-my-server). + + +## Performance + +### Batch size + +The batch size parameter is useful when working with very large lists with millions of subscribers for maximising throughput. It is the number of subscribers that are fetched from the database sequentially in a single cycle (~5 seconds) when a campaign is running. Increasing the batch size uses more memory, but reduces the round trip to the database. diff --git a/docs/docs/content/developer-setup.md b/docs/docs/content/developer-setup.md new file mode 100644 index 000000000..b0e9658da --- /dev/null +++ b/docs/docs/content/developer-setup.md @@ -0,0 +1,42 @@ +# Developer setup +The app has two distinct components, the Go backend and the VueJS frontend. In the dev environment, both are run independently. + + +### Pre-requisites +- `go` +- `nodejs` (if you are working on the frontend) and `yarn` +- Postgres database. If there is no local installation, the demo docker DB can be used for development (`docker compose up demo-db`) + + +### First time setup +`git clone https://github.com/knadh/listmonk.git`. The project uses go.mod, so it's best to clone it outside the Go src path. + +1. Copy `config.toml.sample` as `config.toml` and add your config. +2. `make dist` to build the listmonk binary. Once the binary is built, run `./listmonk --install` to run the DB setup. For subsequent dev runs, use `make run`. + +> [mailhog](https://github.com/mailhog/MailHog) is an excellent standalone mock SMTP server (with a UI) for testing and dev. + + +### Running the dev environment +You can run your dev environment locally or inside containers. + +After setting up the dev environment, you can visit `http://localhost:8080`. + + +1. Locally +- Run `make run` to start the listmonk dev server on `:9000`. +- Run `make run-frontend` to start the Vue frontend in dev mode using yarn on `:8080`. All `/api/*` calls are proxied to the app running on `:9000`. Refer to the [frontend README](https://github.com/knadh/listmonk/blob/master/frontend/README.md) for an overview on how the frontend is structured. + +2. Inside containers (Using Makefile) +- Run `make init-dev-docker` to setup container for db. +- Run `make dev-docker` to setup docker container suite. +- Run `make rm-dev-docker` to clean up docker container suite. + +3. Inside containers (Using devcontainer) +- Open repo in vscode, open command palette, and select "Dev Containers: Rebuild and Reopen in Container". + +It will set up db, and start frontend/backend for you. + + +# Production build +Run `make dist` to build the Go binary, build the Javascript frontend, and embed the static assets producing a single self-contained binary, `listmonk` diff --git a/docs/docs/content/external-integration.md b/docs/docs/content/external-integration.md new file mode 100644 index 000000000..9ced11c0c --- /dev/null +++ b/docs/docs/content/external-integration.md @@ -0,0 +1,11 @@ +# Integrating with external systems + +In many environments, a mailing list manager's subscriber database is not run independently but as a part of an existing customer database or a CRM. There are multiple ways of keeping listmonk in sync with external systems. + +## Using APIs + +The [subscriber APIs](apis/subscribers.md) offers several APIs to manipulate the subscribers database, like addition, updation, and deletion. For bulk synchronisation, a CSV can be generated (and optionally zipped) and posted to the import API. + +## Interacting directly with the DB + +listmonk uses tables with simple schemas to represent subscribers (`subscribers`), lists (`lists`), and subscriptions (`subscriber_lists`). It is easy to add, update, and delete subscriber information directly with the database tables for advanced usecases. See the [table schemas](https://github.com/knadh/listmonk/blob/master/schema.sql) for more information. diff --git a/docs/docs/content/i18n.md b/docs/docs/content/i18n.md new file mode 100644 index 000000000..e8829e54d --- /dev/null +++ b/docs/docs/content/i18n.md @@ -0,0 +1,35 @@ +# Internationalization (i18n) + +listmonk comes available in multiple languages thanks to language packs contributed by volunteers. A language pack is a JSON file with a map of keys and corresponding translations. The bundled languages can be [viewed here](https://github.com/knadh/listmonk/tree/master/i18n). + +## Additional language packs +These additional language packs can be downloaded and passed to listmonk with the `--i18n-dir` flag as described in the next section. + +| Language | Description | +|------------------|--------------------------------------| +| [Deutsch (formal)](https://raw.githubusercontent.com/SvenPe/listmonk/4bbb2e5ebb2314b754cb2318f4f6683a0f854d43/i18n/de.json) | German language with formal pronouns | + + +## Customizing languages + +To customize an existing language or to load a new language, put one or more `.json` language files in a directory, and pass the directory path to listmonk with the+ Changes are stored in the browser's localStorage until the cache is cleared. + To edit an existing language, load it and edit the fields. + To create a new language, load the default language and edit the fields. + Once done, copy the raw JSON and send a PR to the + repo. +
+ ++ Live demo +
++ The latest version is {{ .Page.Site.Data.github.version }} + released on {{ .Page.Site.Data.github.date | dateFormat "02 Jan 2006" }}. + See release notes. +
./listmonk --new-config
to generate config.toml. Edit it.
+ ./listmonk --install
to setup the Postgres DB or --upgrade
to upgrade an existing DB../listmonk
and visit http://localhost:9000
*listmonk has no affiliation with any of these providers.
++ Download and use the sample docker-compose.yml +
+ ++# Download the compose file to the current directory. +curl -LO https://github.com/knadh/listmonk/raw/master/docker-compose.yml + +# Run the services in the background. +docker compose up -d ++
Visit http://localhost:9000
+ Manage millions of subscribers across many single and double opt-in one-way mailing lists + with custom JSON attributes for each subscriber. + Query and segment subscribers with SQL expressions. +
+Use the fast bulk importer (~10k records per second) or use HTTP/JSON APIs or interact with the simple + table schema to integrate external CRMs and subscriber databases. +
++ Simple API to send arbitrary transactional messages to subscribers + using pre-defined templates. Send messages as e-mail, SMS, Whatsapp messages or any medium via Messenger interfaces. +
++ Simple analytics and visualizations. Connect external visualization programs to the database easily with the simple table structure. +
++ Create powerful, dynamic e-mail templates with the Go templating language. + Use template expressions, logic, and 100+ functions in subject lines and content. + Write HTML e-mails in a WYSIWYG editor, Markdown, raw syntax-highlighted HTML, or just plain text. +
++ Multi-threaded, high-throughput, multi-SMTP e-mail queues. + Throughput and sliding window rate limiting for fine grained control. + Single binary application with nominal CPU and memory footprint that runs everywhere. + The only dependency is a Postgres (⩾ 12) database. +
+Use the media manager to upload images for e-mail campaigns + on the server's filesystem, Amazon S3, or any S3 compatible (Minio) backend.
++ More than just e-mail campaigns. Connect HTTP webhooks to send SMS, + Whatsapp, FCM notifications, or any type of messages. +
++ Allow subscribers to permanently blocklist themselves, export all their data, + and to wipe all their data in a single click. +
+