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("[]"), json.RawMessage("{}"), pq.StringArray{"test-campaign"}, emailMsgr, campTplID, pq.Int64Array{1}, false, "welcome-to-listmonk", archiveTplID, `{"name": "Subscriber"}`, nil, nil, ); err != nil { lo.Fatalf("error creating sample campaign: %v", err) } } // recordMigrationVersion inserts the given version (of DB migration) into the // `migrations` array in the settings table. func recordMigrationVersion(ver string, db *sqlx.DB) error { _, err := db.Exec(fmt.Sprintf(`INSERT INTO settings (key, value) VALUES('migrations', '["%s"]'::JSONB) ON CONFLICT (key) DO UPDATE SET value = settings.value || EXCLUDED.value`, ver)) return err } func newConfigFile(path string) error { if _, err := os.Stat(path); !os.IsNotExist(err) { return fmt.Errorf("error creating %s: %v", path, err) } // Initialize the static file system into which all // required static assets (.sql, .js files etc.) are loaded. fs := initFS(appDir, "", "", "") b, err := fs.Read("config.toml.sample") if err != nil { return fmt.Errorf("error reading sample config (is binary stuffed?): %v", err) } return os.WriteFile(path, b, 0644) } // checkSchema checks if the DB schema is installed. func checkSchema(db *sqlx.DB) (bool, error) { if _, err := db.Exec(`SELECT id FROM templates LIMIT 1`); err != nil { if isTableNotExistErr(err) { return false, nil } return false, err } return true, nil } func installUser(username, password, apiUsername string, q *models.Queries) { consts := initConstConfig(ko) // Super Admin role gets all permissions. perms := []string{} for p := range consts.Permissions { perms = append(perms, p) } // Create the Super Admin role in the DB. var role auth.Role if err := q.CreateRole.Get(&role, "Super Admin", auth.RoleTypeUser, pq.Array(perms)); err != nil { lo.Fatalf("error creating super admin role: %v", err) } // Create the admin user. if _, err := q.CreateUser.Exec(username, true, password, username+"@listmonk", username, auth.RoleTypeUser, role.ID, nil, auth.UserStatusEnabled); err != nil { lo.Fatalf("error creating superadmin user: %v", err) } // Create the admin API user. if apiUsername != "" { // Generate a random API token. tk, err := utils.GenerateRandomString(32) if err != nil { lo.Fatalf("error generating API token: %v", err) } var ( email = null.String{String: apiUsername + "@api", Valid: true} password = null.String{String: tk, Valid: true} ) if _, err := q.CreateUser.Exec(apiUsername, false, password, email, apiUsername, auth.UserTypeAPI, role.ID, nil, auth.UserStatusEnabled); err != nil { lo.Fatalf("error creating superadmin API user: %v", err) } // Print the token to stdout so that it can be grepped out. lo.Println("writing API token LISTMONK_ADMIN_API_TOKEN to stderr") fmt.Fprintf(os.Stderr, "export LISTMONK_ADMIN_API_TOKEN=\"%s\"\n", tk) } } ================================================ FILE: cmd/lists.go ================================================ package main import ( "net/http" "strconv" "strings" "github.com/knadh/listmonk/internal/auth" "github.com/knadh/listmonk/models" "github.com/labstack/echo/v4" ) // GetLists retrieves lists with additional metadata like subscriber counts. func (a *App) GetLists(c echo.Context) error { // Get the authenticated user. user := auth.GetUser(c) // Get the list IDs (or blanket permission) the user has access to. hasAllPerm, permittedIDs := user.GetPermittedLists(auth.PermTypeGet) // Minimal query simply returns the list of all lists without JOIN subscriber counts. This is fast. minimal, _ := strconv.ParseBool(c.FormValue("minimal")) if minimal { status := c.FormValue("status") res, err := a.core.GetLists("", status, hasAllPerm, permittedIDs) if err != nil { return err } if len(res) == 0 { return c.JSON(http.StatusOK, okResp{[]struct{}{}}) } // Meta. total := len(res) out := models.PageResults{ Results: res, Total: total, Page: 1, PerPage: total, } return c.JSON(http.StatusOK, okResp{out}) } // Full list query. var ( query = strings.TrimSpace(c.FormValue("query")) tags = c.QueryParams()["tag"] orderBy = c.FormValue("order_by") typ = c.FormValue("type") optin = c.FormValue("optin") status = c.FormValue("status") order = c.FormValue("order") pg = a.pg.NewFromURL(c.Request().URL.Query()) ) res, total, err := a.core.QueryLists(query, typ, optin, status, tags, orderBy, order, hasAllPerm, permittedIDs, pg.Offset, pg.Limit) if err != nil { return err } out := models.PageResults{ Query: query, Results: res, Total: total, Page: pg.Page, PerPage: pg.PerPage, } return c.JSON(http.StatusOK, okResp{out}) } // GetList retrieves a single list by id. // It's permission checked by the listPerm middleware. func (a *App) GetList(c echo.Context) error { // Get the authenticated user. user := auth.GetUser(c) // Check if the user has access to the list. id := getID(c) if err := user.HasListPerm(auth.PermTypeGet, id); err != nil { return err } // Get the list from the DB. out, err := a.core.GetList(id, "") if err != nil { return err } return c.JSON(http.StatusOK, okResp{out}) } // CreateList handles list creation. func (a *App) CreateList(c echo.Context) error { l := models.List{} if err := c.Bind(&l); err != nil { return err } // Validate. if !strHasLen(l.Name, 1, stdInputMaxLen) { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("lists.invalidName")) } out, err := a.core.CreateList(l) if err != nil { return err } return c.JSON(http.StatusOK, okResp{out}) } // UpdateList handles list modification. // It's permission checked by the listPerm middleware. func (a *App) UpdateList(c echo.Context) error { // Get the authenticated user. user := auth.GetUser(c) // Check if the user has access to the list. id := getID(c) if err := user.HasListPerm(auth.PermTypeManage, id); err != nil { return err } // Incoming params. var l models.List if err := c.Bind(&l); err != nil { return err } // Validate. if !strHasLen(l.Name, 1, stdInputMaxLen) { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("lists.invalidName")) } // Update the list in the DB. out, err := a.core.UpdateList(id, l) if err != nil { return err } return c.JSON(http.StatusOK, okResp{out}) } // DeleteList deletes a single list by ID. func (a *App) DeleteList(c echo.Context) error { id := getID(c) // Check if the user has manage permission for the list. user := auth.GetUser(c) if err := user.HasListPerm(auth.PermTypeManage, id); err != nil { return err } // Delete the list from the DB. // Pass getAll=true since we've already verified permissions above. if err := a.core.DeleteLists([]int{id}, "", true, nil); err != nil { return err } return c.JSON(http.StatusOK, okResp{true}) } // DeleteLists deletes multiple lists by IDs or by query. func (a *App) DeleteLists(c echo.Context) error { user := auth.GetUser(c) var ( ids []int query string all bool ) // Check for IDs in query params. if len(c.Request().URL.Query()["id"]) > 0 { var err error ids, err = parseStringIDs(c.Request().URL.Query()["id"]) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error())) } } else { // Check for query param. query = strings.TrimSpace(c.FormValue("query")) all = c.FormValue("all") == "true" } // Validate that either IDs or query is provided. if len(ids) == 0 && (query == "" && !all) { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.errorInvalidIDs", "error", "id or query required")) } // For ID deletion, check if the user has manage permission for the specific lists. if len(ids) > 0 { if err := user.HasListPerm(auth.PermTypeManage, ids...); err != nil { return err } // Delete the lists from the DB. // Pass getAll=true since we've already verified permissions above. if err := a.core.DeleteLists(ids, "", true, nil); err != nil { return err } } else { // For query deletion, get the list IDs the user has manage permission for. hasAllPerm, permittedIDs := user.GetPermittedLists(auth.PermTypeManage) // Delete the lists from the DB with permission filtering. if err := a.core.DeleteLists(nil, query, hasAllPerm, permittedIDs); err != nil { return err } } return c.JSON(http.StatusOK, okResp{true}) } ================================================ FILE: cmd/main.go ================================================ package main import ( "context" "fmt" "io" "log" "os" "os/signal" "strings" "sync" "syscall" "time" "github.com/jmoiron/sqlx" "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/email" "github.com/knadh/listmonk/internal/subimporter" "github.com/knadh/listmonk/models" "github.com/knadh/paginator" "github.com/knadh/stuffbin" ) // App contains the "global" shared components, controllers and fields. type App struct { cfg *Config urlCfg *UrlConfig fs stuffbin.FileSystem db *sqlx.DB queries *models.Queries core *core.Core manager *manager.Manager messengers []manager.Messenger emailMsgr manager.Messenger importer *subimporter.Importer auth *auth.Auth media media.Store bounce *bounce.Manager captcha *captcha.Captcha i18n *i18n.I18n pg *paginator.Paginator events *events.Events log *log.Logger bufLog *buflog.BufLog about about fnOptinNotify func(models.Subscriber, []int) (int, error) // Channel for passing reload signals. 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 } var ( // Buffered log writer for storing N lines of log entries for the UI. 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 *models.Queries // Compile-time variables. buildString string versionString string // If these are set in build ldflags and static assets (*.sql, config.toml.sample. ./frontend) // 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/dist" ) func init() { // Initialize commandline flags. initFlags(ko) // Display version. if ko.Bool("version") { fmt.Println(buildString) os.Exit(0) } lo.Println(buildString) // Generate new config. if ko.Bool("new-config") { path := ko.Strings("config")[0] if err := newConfigFile(path); err != nil { lo.Println(err) os.Exit(1) } lo.Printf("generated %s. Edit and run --install", path) os.Exit(0) } // Load config files to pick up the database settings first. initConfigFiles(ko.Strings("config"), ko) // Load environment variables and merge into the loaded config. // LISTMONK_foo__bar -> foo.bar (double underscore becomes dot for nested config) // LISTMONK_static_dir -> static-dir (top-level keys with underscore become hyphen for CLI flags) if err := ko.Load(env.Provider("LISTMONK_", ".", func(s string) string { key := strings.ToLower(strings.TrimPrefix(s, "LISTMONK_")) key = strings.Replace(key, "__", ".", -1) // Only convert underscore to hyphen for top-level keys (CLI flags like static-dir, i18n-dir) // Nested config keys (containing dots) keep underscores (e.g., db.ssl_mode) if !strings.Contains(key, ".") { key = strings.Replace(key, "_", "-", -1) } return key }), nil); err != nil { lo.Fatalf("error loading config from env: %v", err) } // Connect to the database. db = initDB() // Initialize the embedded filesystem with static assets. fs = initFS(appDir, frontendDir, ko.String("static-dir"), ko.String("i18n-dir")) // Installer mode? This runs before the SQL queries are loaded and prepared // as the installer needs to work on an empty DB. if ko.Bool("install") { // Save the version of the last listed migration. install(migList[len(migList)-1].version, db, fs, !ko.Bool("yes"), ko.Bool("idempotent")) os.Exit(0) } // Is this a nightly build? isNightly := strings.Contains(versionString, "nightly") // Check if the DB schema is installed. if ok, err := checkSchema(db); err != nil { log.Fatalf("error checking schema in DB: %v", err) } else if !ok { lo.Fatal("the database does not appear to be setup. Run --install.") } if ko.Bool("upgrade") { // Even on explicit upgrade runs, for nightly builds, do not record the last // migration version in the DB. lo.Printf("running upgrade...") upgrade(db, fs, !ko.Bool("yes"), !isNightly) os.Exit(0) } // For nightly builds, always auto-run pending migrations without // recording the last version in the DB. Migrations are idempotent, and between // nightly releases, they may change multiple times. if isNightly { lo.Printf("auto-running all migrations for nightly %s since last major version", versionString) upgrade(db, fs, false, false) } else { // Before the queries are prepared, see if there are pending upgrades. checkUpgrade(db) } // Read the SQL queries from the queries file. qMap := readQueries(queryFilePath, fs) // Load settings from DB. if q, ok := qMap["get-settings"]; ok { initSettings(q.Query, db, ko) } // Prepare queries. queries = prepareQueries(qMap, db, ko) } func main() { var ( // Initialize static global config. cfg = initConstConfig(ko) // Initialize static URL config. urlCfg = initUrlConfig(ko) // Initialize i18n language map. i18n = initI18n(ko.MustString("app.lang"), fs) // Initialize the media store. media = initMediaStore(ko) fbOptinNotify = makeOptinNotifyHook(ko.Bool("privacy.unsubscribe_header"), urlCfg, queries, i18n) // Crud core. core = initCore(fbOptinNotify, queries, db, i18n, ko) // Initialize all messengers, SMTP and postback. msgrs = append(initSMTPMessengers(), initPostbackMessengers(ko)...) // Campaign manager. mgr = initCampaignManager(msgrs, queries, urlCfg, core, media, i18n, ko) // Bulk importer. importer = initImporter(queries, db, core, i18n, ko) // Initialize the auth manager. hasUsers, auth = initAuth(core, db.DB, ko) // Initialize the webhook/POP3 bounce processor. bounce *bounce.Manager emailMsgr *email.Emailer chReload = make(chan os.Signal, 1) ) // Initialize the bounce manager that processes bounces from webhooks and // POP3 mailbox scanning. if ko.Bool("bounce.enabled") { bounce = initBounceManager(core.RecordBounce, queries.RecordBounce, lo, ko) } // Assign the default `email` messenger to the app. for _, m := range msgrs { if m.Name() == "email" { emailMsgr = m.(*email.Emailer) } } // Initialize the global admin/sub e-mail notifier. initNotifs(fs, i18n, emailMsgr, urlCfg, ko) // Initialize and cache tx templates in memory. initTxTemplates(mgr, core) // Initialize the bounce manager that processes bounces from webhooks and // POP3 mailbox scanning. if ko.Bool("bounce.enabled") { go bounce.Run() } // Start cronjobs. initCron(core, db) // Start the campaign manager workers. The campaign batches (fetch from DB, push out // messages) get processed at the specified interval. go mgr.Run() // ========================================================================= // Initialize the App{} with all the global shared components, controllers and fields. app := &App{ cfg: cfg, urlCfg: urlCfg, fs: fs, db: db, queries: queries, core: core, manager: mgr, messengers: msgrs, emailMsgr: emailMsgr, importer: importer, auth: auth, media: media, bounce: bounce, captcha: initCaptcha(), i18n: i18n, log: lo, events: evStream, bufLog: bufLog, pg: paginator.New(paginator.Opt{ DefaultPerPage: 20, MaxPerPage: 50, NumPageNums: 10, PageParam: "page", PerPageParam: "per_page", AllowAll: true, }), fnOptinNotify: fbOptinNotify, about: initAbout(queries, db), chReload: chReload, // If there are no users, then the app needs to prompt for new user setup. needsUserSetup: !hasUsers, } // Star the update checker. if ko.Bool("app.check_updates") { go app.checkUpdates(versionString, time.Hour*24) } // Start the app server. srv := initHTTPServer(cfg, urlCfg, i18n, fs, app) // ========================================================================= // 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. signal.Notify(chReload, syscall.SIGHUP) closerWait := make(chan bool) <-awaitReload(chReload, closerWait, func() { // Stop the HTTP server. ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() srv.Shutdown(ctx) // Close the campaign manager. mgr.Close() // Close the DB pool. db.Close() // Close the messenger pool. for _, m := range app.messengers { m.Close() } // Signal the close. closerWait <- true }) } ================================================ FILE: cmd/maintenance.go ================================================ package main import ( "log" "net/http" "time" "github.com/jmoiron/sqlx" "github.com/labstack/echo/v4" ) // GCSubscribers garbage collects (deletes) orphaned or blocklisted subscribers. func (a *App) GCSubscribers(c echo.Context) error { var ( typ = c.Param("type") n int err error ) switch typ { case "blocklisted": n, err = a.core.DeleteBlocklistedSubscribers() case "orphan": n, err = a.core.DeleteOrphanSubscribers() default: err = echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("globals.messages.invalidData")) } if err != nil { return err } return c.JSON(http.StatusOK, okResp{struct { Count int `json:"count"` }{n}}) } // GCSubscriptions garbage collects (deletes) orphaned or blocklisted subscribers. func (a *App) GCSubscriptions(c echo.Context) error { // Validate the date. t, err := time.Parse(time.RFC3339, c.FormValue("before_date")) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("globals.messages.invalidData")) } // Delete unconfirmed subscriptions from the DB in bulk. n, err := a.core.DeleteUnconfirmedSubscriptions(t) if err != nil { return err } return c.JSON(http.StatusOK, okResp{struct { Count int `json:"count"` }{n}}) } // GCCampaignAnalytics garbage collects (deletes) campaign analytics. func (a *App) GCCampaignAnalytics(c echo.Context) error { t, err := time.Parse(time.RFC3339, c.FormValue("before_date")) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("globals.messages.invalidData")) } switch c.Param("type") { case "all": if err := a.core.DeleteCampaignViews(t); err != nil { return err } err = a.core.DeleteCampaignLinkClicks(t) case "views": err = a.core.DeleteCampaignViews(t) case "clicks": err = a.core.DeleteCampaignLinkClicks(t) default: err = echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("globals.messages.invalidData")) } if err != nil { return err } return c.JSON(http.StatusOK, okResp{true}) } // RunDBVacuum runs a full VACUUM on the PostgreSQL database. // VACUUM reclaims storage occupied by dead tuples and updates planner statistics. func RunDBVacuum(db *sqlx.DB, lo *log.Logger) { lo.Println("running database VACUUM ANALYZE") if _, err := db.Exec("VACUUM ANALYZE"); err != nil { lo.Printf("error running VACUUM ANALYZE: %v", err) return } lo.Println("finished database VACUUM ANALYZE") } ================================================ FILE: cmd/manager_store.go ================================================ package main import ( "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" ) // store implements DataSource over the primary // database. type store struct { queries *models.Queries core *core.Core media media.Store } 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 *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 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 := s.queries.NextCampaigns.Select(&out, pq.Int64Array(currentIDs), pq.Int64Array(sentCounts)) return out, err } // NextSubscribers retrieves a subset of subscribers of a given campaign. // 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 (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 := 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 (s *store) GetCampaign(campID int) (*models.Campaign, error) { var out = &models.Campaign{} err := s.queries.GetCampaign.Get(out, campID, nil, nil, "default") return out, err } // UpdateCampaignStatus updates a campaign's 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 (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() if err != nil { return "", err } var out string if err := s.queries.CreateLink.Get(&out, uu, url); err != nil { return "", err } return out, nil } // RecordBounce records a bounce event and returns the bounce count. func (s *store) RecordBounce(b models.Bounce) (int64, int, error) { var res = struct { SubscriberID int64 `db:"subscriber_id"` Num int `db:"num"` }{} err := s.queries.UpdateCampaignStatus.Select(&res, b.SubscriberUUID, b.Email, b.CampaignUUID, b.Type, b.Source, b.Meta) return res.SubscriberID, res.Num, err } // BlocklistSubscriber blocklists a subscriber permanently. func (s *store) BlocklistSubscriber(id int64) error { _, err := s.queries.BlocklistSubscribers.Exec(pq.Int64Array{id}) return err } // DeleteSubscriber deletes a subscriber from the DB. func (s *store) DeleteSubscriber(id int64) error { _, err := s.queries.DeleteSubscribers.Exec(pq.Int64Array{id}) return err } ================================================ FILE: cmd/media.go ================================================ package main import ( "bytes" "mime/multipart" "net/http" "path/filepath" "strings" "github.com/disintegration/imaging" "github.com/knadh/listmonk/models" "github.com/labstack/echo/v4" ) const ( thumbPrefix = "thumb_" thumbnailSize = 250 ) var ( vectorExts = []string{"svg"} imageExts = []string{"gif", "png", "jpg", "jpeg"} ) // UploadMedia handles media file uploads. func (a *App) UploadMedia(c echo.Context) error { file, err := c.FormFile("file") if err != nil { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("media.invalidFile", "error", err.Error())) } // Read the file from the HTTP form. src, err := file.Open() if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.Ts("media.errorReadingFile", "error", err.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") ) // Validate file extension. if !inArray("*", a.cfg.MediaUpload.Extensions) { if ok := inArray(ext, a.cfg.MediaUpload.Extensions); !ok { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("media.unsupportedFileType", "type", ext)) } } // Sanitize the filename. fName := makeFilename(file.Filename) // If the filename already exists in the DB, make it unique by adding a random suffix. if _, err := a.core.GetMedia(0, "", fName, a.media); err == nil { suffix, err := generateRandomString(6) if err != nil { a.log.Printf("error generating random string: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("globals.messages.internalError")) } fName = appendSuffixToFilename(fName, suffix) } // Upload the file to the media store. fName, err = a.media.Put(fName, contentType, src) if err != nil { a.log.Printf("error uploading file: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.Ts("media.errorUploading", "error", err.Error())) } // This keeps track of whether the file has to be deleted from the DB and the store // if any of the subsequent steps fail. var ( cleanUp = false thumbfName = "" ) defer func() { if cleanUp { a.media.Delete(fName) if thumbfName != "" { a.media.Delete(thumbfName) } } }() // Thumbnail width and height. var width, height int // Create thumbnail from file for non-vector formats. isImage := inArray(ext, imageExts) if isImage { thumbFile, wi, he, err := processImage(file) if err != nil { cleanUp = true a.log.Printf("error resizing image: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.Ts("media.errorResizing", "error", err.Error())) } width = wi height = he // Upload thumbnail. tf, err := a.media.Put(thumbPrefix+fName, contentType, thumbFile) if err != nil { cleanUp = true a.log.Printf("error saving thumbnail: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.Ts("media.errorSavingThumbnail", "error", err.Error())) } thumbfName = tf } if inArray(ext, vectorExts) { thumbfName = fName } // Images have metadata. meta := models.JSON{} if isImage { meta = models.JSON{ "width": width, "height": height, } } // Insert the media into the DB. m, err := a.core.InsertMedia(fName, thumbfName, contentType, meta, a.cfg.MediaUpload.Provider, a.media) if err != nil { cleanUp = true return err } return c.JSON(http.StatusOK, okResp{m}) } // GetAllMedia handles retrieval of uploaded media. func (a *App) GetAllMedia(c echo.Context) error { var ( query = c.FormValue("query") pg = a.pg.NewFromURL(c.Request().URL.Query()) ) // Fetch the media items from the DB. res, total, err := a.core.QueryMedia(a.cfg.MediaUpload.Provider, a.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}) } // GetMedia handles retrieval of a media item by ID. func (a *App) GetMedia(c echo.Context) error { // Fetch the media item from the DB. id := getID(c) out, err := a.core.GetMedia(id, "", "", a.media) if err != nil { return err } return c.JSON(http.StatusOK, okResp{out}) } // DeleteMedia handles deletion of uploaded media. func (a *App) DeleteMedia(c echo.Context) error { // Delete the media from the DB. The query returns the filename. id := getID(c) fname, err := a.core.DeleteMedia(id) if err != nil { return err } // Delete the files from the media store. a.media.Delete(fname) a.media.Delete(thumbPrefix + fname) return c.JSON(http.StatusOK, okResp{true}) } // ServeS3Media serves media files stored in S3 when the public URL is a relative path. func (a *App) ServeS3Media(c echo.Context) error { key := c.Param("filepath") if key == "" { return echo.NewHTTPError(http.StatusBadRequest, "missing media file path") } b, err := a.media.GetBlob(key) if err != nil { a.log.Printf("error fetching media from s3 %s: %v", key, err) return echo.NewHTTPError(http.StatusInternalServerError, "error fetching media") } return c.Stream(http.StatusOK, http.DetectContentType(b), bytes.NewReader(b)) } // 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, 0, 0, err } defer src.Close() img, err := imaging.Decode(src) if err != nil { return nil, 0, 0, err } // Encode the image into a byte slice as PNG. var ( thumb = imaging.Resize(img, thumbnailSize, 0, imaging.Lanczos) out bytes.Buffer ) if err := imaging.Encode(&out, thumb, imaging.PNG); err != nil { return nil, 0, 0, err } b := img.Bounds().Max return bytes.NewReader(out.Bytes()), b.X, b.Y, nil } ================================================ FILE: cmd/public.go ================================================ package main import ( "bytes" "database/sql" "fmt" "html/template" "image" "image/png" "io" "net/http" "strconv" "strings" "github.com/knadh/listmonk/internal/captcha" "github.com/knadh/listmonk/internal/i18n" "github.com/knadh/listmonk/internal/manager" "github.com/knadh/listmonk/internal/notifs" "github.com/knadh/listmonk/models" "github.com/labstack/echo/v4" "github.com/lib/pq" ) const ( tplMessage = "message" ) // tplRenderer wraps a template.tplRenderer for echo. type tplRenderer struct { templates *template.Template SiteName string RootURL string LogoURL string FaviconURL string AssetVersion string EnablePublicSubPage bool EnablePublicArchive bool IndividualTracking bool } // tplData is the data container that is injected // into public templates for accessing data. type tplData struct { SiteName string RootURL string LogoURL string FaviconURL string AssetVersion string EnablePublicSubPage bool EnablePublicArchive bool IndividualTracking bool Data any L *i18n.I18n } type publicTpl struct { Title string Description string } type unsubTpl struct { publicTpl Subscriber models.Subscriber Subscriptions []models.Subscription SubUUID string AllowBlocklist bool AllowExport bool AllowWipe bool AllowPreferences bool ShowManage bool } type optinReq struct { SubUUID string ListUUIDs []string `query:"l" form:"l"` Lists []models.List `query:"-" form:"-"` } type optinTpl struct { publicTpl optinReq } type msgTpl struct { publicTpl MessageTitle string Message string } type subFormTpl struct { publicTpl Lists []models.List Captcha struct { Enabled bool Provider string Key string Complexity int } } var ( pixelPNG = drawTransparentImage(3, 14) ) // Render executes and renders a template for echo. func (t *tplRenderer) Render(w io.Writer, name string, data any, c echo.Context) error { return t.templates.ExecuteTemplate(w, name, tplData{ SiteName: t.SiteName, RootURL: t.RootURL, LogoURL: t.LogoURL, FaviconURL: t.FaviconURL, AssetVersion: t.AssetVersion, EnablePublicSubPage: t.EnablePublicSubPage, EnablePublicArchive: t.EnablePublicArchive, IndividualTracking: t.IndividualTracking, Data: data, L: c.Get("app").(*App).i18n, }) } // GetPublicLists returns the list of public lists with minimal fields // required to submit a subscription. func (a *App) GetPublicLists(c echo.Context) error { // Get all public lists. lists, err := a.core.GetLists(models.ListTypePublic, models.ListStatusActive, true, nil) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("public.errorFetchingLists")) } type list struct { UUID string `json:"uuid"` Name string `json:"name"` } out := make([]list, 0, len(lists)) for _, l := range lists { out = append(out, list{ UUID: l.UUID, Name: l.Name, }) } return c.JSON(http.StatusOK, out) } // ViewCampaignMessage renders the HTML view of a campaign message. // This is the view the {{ MessageURL }} template tag links to in e-mail campaigns. func (a *App) ViewCampaignMessage(c echo.Context) error { // Get the campaign. campUUID := c.Param("campUUID") camp, err := a.core.GetCampaign(0, campUUID, "") if err != nil { if er, ok := err.(*echo.HTTPError); ok { if er.Code == http.StatusBadRequest { return c.Render(http.StatusNotFound, tplMessage, makeMsgTpl(a.i18n.T("public.notFoundTitle"), "", a.i18n.T("public.campaignNotFound"))) } } return c.Render(http.StatusInternalServerError, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.Ts("public.errorFetchingCampaign"))) } // Get the subscriber. subUUID := c.Param("subUUID") sub, err := a.core.GetSubscriber(0, subUUID, "") if err != nil { if err == sql.ErrNoRows { return c.Render(http.StatusNotFound, tplMessage, makeMsgTpl(a.i18n.T("public.notFoundTitle"), "", a.i18n.T("public.errorFetchingEmail"))) } return c.Render(http.StatusInternalServerError, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.Ts("public.errorFetchingCampaign"))) } // Compile the template. if err := camp.CompileTemplate(a.manager.TemplateFuncs(&camp)); err != nil { a.log.Printf("error compiling template: %v", err) return c.Render(http.StatusInternalServerError, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.Ts("public.errorFetchingCampaign"))) } // Render the message body. msg, err := a.manager.NewCampaignMessage(&camp, sub) if err != nil { a.log.Printf("error rendering message: %v", err) return c.Render(http.StatusInternalServerError, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.Ts("public.errorFetchingCampaign"))) } return c.HTML(http.StatusOK, string(msg.Body())) } // SubscriptionPage renders the subscription management page and handles unsubscriptions. // This is the view that {{ UnsubscribeURL }} in campaigns link to. func (a *App) SubscriptionPage(c echo.Context) error { var ( subUUID = c.Param("subUUID") showManage, _ = strconv.ParseBool(c.FormValue("manage")) ) // Get the subscriber from the DB. s, err := a.core.GetSubscriber(0, subUUID, "") if err != nil { return c.Render(http.StatusInternalServerError, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.Ts("public.errorProcessingRequest"))) } // Prepare the public template. out := unsubTpl{ Subscriber: s, SubUUID: subUUID, publicTpl: publicTpl{Title: a.i18n.T("public.unsubscribeTitle")}, AllowBlocklist: a.cfg.Privacy.AllowBlocklist, AllowExport: a.cfg.Privacy.AllowExport, AllowWipe: a.cfg.Privacy.AllowWipe, AllowPreferences: a.cfg.Privacy.AllowPreferences, } // If the subscriber is blocklisted, throw an error. if s.Status == models.SubscriberStatusBlockListed { return c.Render(http.StatusOK, tplMessage, makeMsgTpl(a.i18n.T("public.noSubTitle"), "", a.i18n.Ts("public.blocklisted"))) } // Only show preference management if it's enabled in settings. if a.cfg.Privacy.AllowPreferences { out.ShowManage = showManage // Get the subscriber's lists from the DB to render in the template. subs, err := a.core.GetSubscriptions(0, subUUID, false) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("public.errorFetchingLists")) } out.Subscriptions = make([]models.Subscription, 0, len(subs)) for _, s := range subs { // Private lists shouldn't be rendered in the template. if s.Type == models.ListTypePrivate { continue } out.Subscriptions = append(out.Subscriptions, s) } } return c.Render(http.StatusOK, "subscription", out) } // SubscriptionPrefs renders the subscription management page and // s unsubscriptions. This is the view that {{ UnsubscribeURL }} in // campaigns link to. func (a *App) SubscriptionPrefs(c echo.Context) error { // Read the form. var req struct { Name string `form:"name" json:"name"` ListUUIDs []string `form:"l" json:"list_uuids"` Blocklist bool `form:"blocklist" json:"blocklist"` Manage bool `form:"manage" json:"manage"` } if err := c.Bind(&req); err != nil { return c.Render(http.StatusBadRequest, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.T("globals.messages.invalidData"))) } // Simple unsubscribe. var ( campUUID = c.Param("campUUID") subUUID = c.Param("subUUID") blocklist = a.cfg.Privacy.AllowBlocklist && req.Blocklist ) if !req.Manage || blocklist { if err := a.core.UnsubscribeByCampaign(subUUID, campUUID, blocklist); err != nil { return c.Render(http.StatusInternalServerError, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.T("public.errorProcessingRequest"))) } return c.Render(http.StatusOK, tplMessage, makeMsgTpl(a.i18n.T("public.unsubbedTitle"), "", a.i18n.T("public.unsubbedInfo"))) } // Is preference management enabled? if !a.cfg.Privacy.AllowPreferences { return c.Render(http.StatusBadRequest, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.T("public.invalidFeature"))) } // Manage preferences. req.Name = strings.TrimSpace(req.Name) if req.Name == "" || len(req.Name) > 256 { return c.Render(http.StatusBadRequest, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.T("subscribers.invalidName"))) } // Get the subscriber from the DB. sub, err := a.core.GetSubscriber(0, subUUID, "") if err != nil { return c.Render(http.StatusInternalServerError, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.Ts("globals.messages.pFound", "name", a.i18n.T("globals.terms.subscriber")))) } sub.Name = req.Name // Update the subscriber properties in the DB. if _, err := a.core.UpdateSubscriber(sub.ID, sub); err != nil { return c.Render(http.StatusInternalServerError, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.T("public.errorProcessingRequest"))) } // Get the subscriber's lists and whatever is not sent in the request (unchecked), // unsubscribe them. reqUUIDs := make(map[string]struct{}) for _, u := range req.ListUUIDs { reqUUIDs[u] = struct{}{} } // Get subscription from teh DB. subs, err := a.core.GetSubscriptions(0, subUUID, false) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("public.errorFetchingLists")) } // Filter the lists in the request against the subscriptions in the DB. unsubUUIDs := make([]string, 0, len(req.ListUUIDs)) for _, s := range subs { if s.Type == models.ListTypePrivate { continue } if _, ok := reqUUIDs[s.UUID]; !ok { unsubUUIDs = append(unsubUUIDs, s.UUID) } } // Unsubscribe from lists. if err := a.core.UnsubscribeLists([]int{sub.ID}, nil, unsubUUIDs); err != nil { return c.Render(http.StatusInternalServerError, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.T("public.errorProcessingRequest"))) } return c.Render(http.StatusOK, tplMessage, makeMsgTpl(a.i18n.T("globals.messages.done"), "", a.i18n.T("public.prefsSaved"))) } // OptinPage renders the double opt-in confirmation page that subscribers // see when they click on the "Confirm subscription" button in double-optin // notifications. func (a *App) OptinPage(c echo.Context) error { var ( subUUID = c.Param("subUUID") confirm, _ = strconv.ParseBool(c.FormValue("confirm")) req optinReq ) if err := c.Bind(&req); err != nil { return err } // Validate list UUIDs if there are incoming UUIDs in the request. if len(req.ListUUIDs) > 0 { for _, l := range req.ListUUIDs { if !reUUID.MatchString(l) { return c.Render(http.StatusBadRequest, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.T("globals.messages.invalidUUID"))) } } } // Get the list of subscription lists where the subscriber hasn't confirmed. lists, err := a.core.GetSubscriberLists(0, subUUID, nil, req.ListUUIDs, models.SubscriptionStatusUnconfirmed, "") if err != nil { return c.Render(http.StatusInternalServerError, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.Ts("public.errorFetchingLists"))) } // There are no lists to confirm. if len(lists) == 0 { return c.Render(http.StatusOK, tplMessage, makeMsgTpl(a.i18n.T("public.noSubTitle"), "", a.i18n.Ts("public.noSubInfo"))) } // Confirm. if confirm { meta := models.JSON{} if a.cfg.Privacy.RecordOptinIP { if h := c.Request().Header.Get("X-Forwarded-For"); h != "" { meta["optin_ip"] = h } else if h := c.Request().RemoteAddr; h != "" { meta["optin_ip"] = strings.Split(h, ":")[0] } } // Confirm subscriptions in the DB. if err := a.core.ConfirmOptionSubscription(subUUID, req.ListUUIDs, meta); err != nil { a.log.Printf("error unsubscribing: %v", err) return c.Render(http.StatusInternalServerError, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.Ts("public.errorProcessingRequest"))) } return c.Render(http.StatusOK, tplMessage, makeMsgTpl(a.i18n.T("public.subConfirmedTitle"), "", a.i18n.Ts("public.subConfirmed"))) } var out optinTpl out.Lists = lists out.SubUUID = subUUID out.Title = a.i18n.T("public.confirmOptinSubTitle") return c.Render(http.StatusOK, "optin", out) } // SubscriptionFormPage handles subscription requests coming from public // HTML subscription forms. func (a *App) SubscriptionFormPage(c echo.Context) error { if !a.cfg.EnablePublicSubPage { return c.Render(http.StatusNotFound, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.Ts("public.invalidFeature"))) } // Get all public lists from the DB. lists, err := a.core.GetLists(models.ListTypePublic, models.ListStatusActive, true, nil) if err != nil { return c.Render(http.StatusInternalServerError, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.Ts("public.errorFetchingLists"))) } // There are no public lists available for subscription. if len(lists) == 0 { return c.Render(http.StatusInternalServerError, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.Ts("public.noListsAvailable"))) } out := subFormTpl{} out.Title = a.i18n.T("public.sub") out.Lists = lists // Captcha configuration for template rendering. if a.cfg.Security.Captcha.Altcha.Enabled { out.Captcha.Enabled = true out.Captcha.Provider = "altcha" out.Captcha.Complexity = a.cfg.Security.Captcha.Altcha.Complexity } else if a.cfg.Security.Captcha.HCaptcha.Enabled { out.Captcha.Enabled = true out.Captcha.Provider = "hcaptcha" out.Captcha.Key = a.cfg.Security.Captcha.HCaptcha.Key } return c.Render(http.StatusOK, "subscription-form", out) } // SubscriptionForm handles subscription requests coming from public // HTML subscription forms. func (a *App) SubscriptionForm(c echo.Context) error { if !a.cfg.EnablePublicSubPage { return echo.NewHTTPError(http.StatusNotFound, a.i18n.T("public.invalidFeature")) } // If there's a nonce value, a bot could've filled the form. if c.FormValue("nonce") != "" { return echo.NewHTTPError(http.StatusBadGateway, a.i18n.T("public.invalidFeature")) } // Process CAPTCHA. if a.captcha.IsEnabled() { var val string // Get the appropriate captcha response field based on provider. switch a.captcha.GetProvider() { case captcha.ProviderHCaptcha: val = c.FormValue("h-captcha-response") case captcha.ProviderAltcha: val = c.FormValue("altcha") default: return c.Render(http.StatusBadRequest, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.T("public.invalidCaptcha"))) } if val == "" { return c.Render(http.StatusBadRequest, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.T("public.invalidCaptcha"))) } err, ok := a.captcha.Verify(val) if err != nil { a.log.Printf("captcha request failed: %v", err) } if !ok { return c.Render(http.StatusBadRequest, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.T("public.invalidCaptcha"))) } } hasOptin, err := a.processSubForm(c) if err != nil { e, ok := err.(*echo.HTTPError) if !ok { return err } return c.Render(e.Code, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", fmt.Sprintf("%s", e.Message))) } // If there were double optin lists, show the opt-in pending message instead of // the subscription confirmation message. msg := "public.subConfirmed" if hasOptin { msg = "public.subOptinPending" } return c.Render(http.StatusOK, tplMessage, makeMsgTpl(a.i18n.T("public.subTitle"), "", a.i18n.Ts(msg))) } // PublicSubscription handles subscription requests coming from public // API calls. func (a *App) PublicSubscription(c echo.Context) error { if !a.cfg.EnablePublicSubPage { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("public.invalidFeature")) } hasOptin, err := a.processSubForm(c) if err != nil { return err } return c.JSON(http.StatusOK, okResp{struct { HasOptin bool `json:"has_optin"` }{hasOptin}}) } // LinkRedirect redirects a link UUID to its original underlying link // after recording the link click for a particular subscriber in the particular // campaign. These links are generated by {{ TrackLink }} tags in campaigns. func (a *App) LinkRedirect(c echo.Context) error { var ( linkUUID = c.Param("linkUUID") campUUID = c.Param("campUUID") ) // If tracking is globally disabled, resolve the URL without recording a click. if a.cfg.Privacy.DisableTracking { url, err := a.core.GetLinkURL(linkUUID) if err != nil { e := err.(*echo.HTTPError) return c.Render(e.Code, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", e.Error())) } return c.Redirect(http.StatusTemporaryRedirect, url) } // If individual tracking is disabled, do not record the subscriber ID. subUUID := c.Param("subUUID") if !a.cfg.Privacy.IndividualTracking { subUUID = "" } url, err := a.core.RegisterCampaignLinkClick(linkUUID, campUUID, subUUID) if err != nil { e := err.(*echo.HTTPError) return c.Render(e.Code, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", e.Error())) } return c.Redirect(http.StatusTemporaryRedirect, url) } // RegisterCampaignView registers a campaign view which comes in // the form of an pixel image request. Regardless of errors, this handler // should always render the pixel image bytes. The pixel URL is generated by // the {{ TrackView }} template tag in campaigns. func (a *App) RegisterCampaignView(c echo.Context) error { // If tracking is globally disabled, return the pixel without recording. if a.cfg.Privacy.DisableTracking { c.Response().Header().Set("Cache-Control", "no-cache") return c.Blob(http.StatusOK, "image/png", pixelPNG) } // If individual tracking is disabled, do not record the subscriber ID. subUUID := c.Param("subUUID") if !a.cfg.Privacy.IndividualTracking { subUUID = "" } // Exclude dummy hits from template previews. campUUID := c.Param("campUUID") if campUUID != dummyUUID && subUUID != dummyUUID { if err := a.core.RegisterCampaignView(campUUID, subUUID); err != nil { a.log.Printf("error registering campaign view: %s", err) } } c.Response().Header().Set("Cache-Control", "no-cache") return c.Blob(http.StatusOK, "image/png", pixelPNG) } // SelfExportSubscriberData pulls the subscriber's profile, list subscriptions, // campaign views and clicks and produces a JSON report that is then e-mailed // to the subscriber. This is a privacy feature and the data that's exported // is dependent on the configuration. func (a *App) SelfExportSubscriberData(c echo.Context) error { // Is export allowed? if !a.cfg.Privacy.AllowExport { return c.Render(http.StatusBadRequest, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.Ts("public.invalidFeature"))) } // Get the subscriber's data. A single query that gets the profile, // list subscriptions, campaign views, and link clicks. Names of // private lists are replaced with "Private list". subUUID := c.Param("subUUID") data, b, err := a.exportSubscriberData(0, subUUID, a.cfg.Privacy.Exportable) if err != nil { a.log.Printf("error exporting subscriber data: %s", err) return c.Render(http.StatusInternalServerError, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.Ts("public.errorProcessingRequest"))) } // Prepare the attachment e-mail. var msg bytes.Buffer if err := notifs.Tpls.ExecuteTemplate(&msg, notifs.TplSubscriberData, data); err != nil { a.log.Printf("error compiling notification template '%s': %v", notifs.TplSubscriberData, err) return c.Render(http.StatusInternalServerError, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.Ts("public.errorProcessingRequest"))) } // TODO: GetTplSubject should be moved to a utils package. subject, body := notifs.GetTplSubject(a.i18n.Ts("email.data.title"), msg.Bytes()) // E-mail the data as a JSON attachment to the subscriber. const fname = "data.json" if err := a.emailMsgr.Push(models.Message{ From: a.cfg.FromEmail, To: []string{data.Email}, Subject: subject, Body: body, Attachments: []models.Attachment{ { Name: fname, Content: b, Header: manager.MakeAttachmentHeader(fname, "base64", "application/json"), }, }, }); err != nil { a.log.Printf("error e-mailing subscriber profile: %s", err) return c.Render(http.StatusInternalServerError, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.Ts("public.errorProcessingRequest"))) } return c.Render(http.StatusOK, tplMessage, makeMsgTpl(a.i18n.T("public.dataSentTitle"), "", a.i18n.T("public.dataSent"))) } // WipeSubscriberData allows a subscriber to delete their data. The // profile and subscriptions are deleted, while the campaign_views and link // clicks remain as orphan data unconnected to any subscriber. func (a *App) WipeSubscriberData(c echo.Context) error { // Is wiping allowed? if !a.cfg.Privacy.AllowWipe { return c.Render(http.StatusBadRequest, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.Ts("public.invalidFeature"))) } subUUID := c.Param("subUUID") if err := a.core.DeleteSubscribers(nil, []string{subUUID}); err != nil { a.log.Printf("error wiping subscriber data: %s", err) return c.Render(http.StatusInternalServerError, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.Ts("public.errorProcessingRequest"))) } return c.Render(http.StatusOK, tplMessage, makeMsgTpl(a.i18n.T("public.dataRemovedTitle"), "", a.i18n.T("public.dataRemoved"))) } // AltchaChallenge generates a challenge for Altcha captcha. func (a *App) AltchaChallenge(c echo.Context) error { // Check if Altcha is enabled. if !a.captcha.IsEnabled() || a.captcha.GetProvider() != captcha.ProviderAltcha { return echo.NewHTTPError(http.StatusNotFound, "captcha not enabled") } // Generate challenge. out, err := a.captcha.GenerateChallenge() if err != nil { a.log.Printf("error generating altcha challenge: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, "Error generating challenge") } // Return the challenge as JSON. c.Response().Header().Set("Content-Type", "application/json") return c.String(http.StatusOK, out) } // drawTransparentImage draws a transparent PNG of given dimensions // and returns the PNG bytes. func drawTransparentImage(h, w int) []byte { var ( img = image.NewRGBA(image.Rect(0, 0, w, h)) out = &bytes.Buffer{} ) _ = png.Encode(out, img) return out.Bytes() } // processSubForm processes an incoming form/public API subscription request. // The bool indicates whether there was subscription to an optin list so that // an appropriate message can be shown. func (a *App) processSubForm(c echo.Context) (bool, error) { // Get and validate fields. var req struct { Name string `form:"name" json:"name"` Email string `form:"email" json:"email"` FormListUUIDs []string `form:"l" json:"list_uuids"` } if err := c.Bind(&req); err != nil { return false, err } if len(req.FormListUUIDs) == 0 { return false, echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("public.noListsSelected")) } // Validate fields. if len(req.Email) > 1000 { return false, echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("subscribers.invalidEmail")) } em, err := a.importer.SanitizeEmail(req.Email) if err != nil { return false, echo.NewHTTPError(http.StatusBadRequest, err.Error()) } req.Email = em req.Name = strings.TrimSpace(req.Name) if len(req.Name) == 0 { // If there's no name, use the name bit from the e-mail. req.Name = strings.Split(req.Email, "@")[0] } else if len(req.Name) > stdInputMaxLen { return false, echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("subscribers.invalidName")) } listUUIDs := pq.StringArray(req.FormListUUIDs) // Fetch the list types and ensure that they are not private. listTypes, err := a.core.GetListTypes(nil, req.FormListUUIDs) if err != nil { return false, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("%s", err.(*echo.HTTPError).Message)) } for _, t := range listTypes { if t == models.ListTypePrivate { return false, echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("globals.messages.invalidUUID")) } } // Insert the subscriber into the DB. _, hasOptin, err := a.core.InsertSubscriber(models.Subscriber{ Name: req.Name, Email: req.Email, Status: models.SubscriberStatusEnabled, }, nil, listUUIDs, false, true) if err == nil { return hasOptin, nil } // Insert returned an error. Examine it. var lastErr = err // Subscriber already exists. Update subscriptions in the DB. if e, ok := err.(*echo.HTTPError); ok && e.Code == http.StatusConflict { // Get the subscriber from the DB by their email. sub, err := a.core.GetSubscriber(0, "", req.Email) if err != nil { return false, err } // Update the subscriber's subscriptions in the DB. _, hasOptin, err := a.core.UpdateSubscriberWithLists(sub.ID, sub, nil, listUUIDs, false, false, true, nil) if err == nil { return hasOptin, nil } lastErr = err } // Something else went wrong. if e, ok := lastErr.(*echo.HTTPError); ok { return false, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("%s", e.Message)) } return false, echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("public.errorProcessingRequest")) } ================================================ FILE: cmd/roles.go ================================================ package main import ( "fmt" "net/http" "strings" "github.com/knadh/listmonk/internal/auth" "github.com/labstack/echo/v4" ) // GetUserRoles retrieves roles. func (a *App) GetUserRoles(c echo.Context) error { // Get all roles. out, err := a.core.GetRoles() if err != nil { return err } return c.JSON(http.StatusOK, okResp{out}) } // GeListRoles retrieves roles. func (a *App) GeListRoles(c echo.Context) error { // Get all roles. out, err := a.core.GetListRoles() if err != nil { return err } return c.JSON(http.StatusOK, okResp{out}) } // CreateUserRole handles role creation. func (a *App) CreateUserRole(c echo.Context) error { var r auth.Role if err := c.Bind(&r); err != nil { return err } if err := a.validateUserRole(r); err != nil { return err } // Create the role in the DB. out, err := a.core.CreateRole(r) if err != nil { return err } return c.JSON(http.StatusOK, okResp{out}) } // CreateListRole handles role creation. func (a *App) CreateListRole(c echo.Context) error { var r auth.ListRole if err := c.Bind(&r); err != nil { return err } if err := a.validateListRole(r); err != nil { return err } // Create the role in the DB. out, err := a.core.CreateListRole(r) if err != nil { return err } return c.JSON(http.StatusOK, okResp{out}) } // UpdateUserRole handles role modification. func (a *App) UpdateUserRole(c echo.Context) error { id := getID(c) // ID 1 is reserved for the Super Admin user role. if id == auth.SuperAdminRoleID { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("globals.messages.invalidID")) } // Incoming params. var r auth.Role if err := c.Bind(&r); err != nil { return err } if err := a.validateUserRole(r); err != nil { return err } // Validate. r.Name.String = strings.TrimSpace(r.Name.String) // Update the role in the DB. out, err := a.core.UpdateUserRole(id, r) if err != nil { return err } // Cache API tokens for in-memory, off-DB /api/* request auth. if _, err := cacheUsers(a.core, a.auth); err != nil { return err } return c.JSON(http.StatusOK, okResp{out}) } // UpdateListRole handles role modification. func (a *App) UpdateListRole(c echo.Context) error { // Get the role ID. id := getID(c) // ID 1 is reserved for the Super Admin user role. if id == auth.SuperAdminRoleID { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("globals.messages.invalidID")) } // Incoming params. var r auth.ListRole if err := c.Bind(&r); err != nil { return err } if err := a.validateListRole(r); err != nil { return err } // Validate. r.Name.String = strings.TrimSpace(r.Name.String) // Update the role in the DB. out, err := a.core.UpdateListRole(id, r) if err != nil { return err } // Cache API tokens for in-memory, off-DB /api/* request auth. if _, err := cacheUsers(a.core, a.auth); err != nil { return err } return c.JSON(http.StatusOK, okResp{out}) } // DeleteRole handles (user|list) role deletion. func (a *App) DeleteRole(c echo.Context) error { // Get the role ID. id := getID(c) // ID 1 is reserved for the Super Admin user role. if id == auth.SuperAdminRoleID { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("globals.messages.invalidID")) } // Delete the role from the DB. if err := a.core.DeleteRole(int(id)); err != nil { return err } // Cache API tokens for in-memory, off-DB /api/* request auth. if _, err := cacheUsers(a.core, a.auth); err != nil { return err } return c.JSON(http.StatusOK, okResp{true}) } func (a *App) validateUserRole(r auth.Role) error { if !strHasLen(r.Name.String, 1, stdInputMaxLen) { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "name")) } for _, p := range r.Permissions { if _, ok := a.cfg.Permissions[p]; !ok { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", fmt.Sprintf("permission: %s", p))) } } return nil } func (a *App) validateListRole(r auth.ListRole) error { if !strHasLen(r.Name.String, 1, stdInputMaxLen) { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "name")) } for _, l := range r.Lists { for _, p := range l.Permissions { if p != auth.PermListGet && p != auth.PermListManage { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", fmt.Sprintf("list permission: %s", p))) } } } return nil } ================================================ FILE: cmd/settings.go ================================================ package main import ( "bytes" "encoding/json" "io" "net/http" "net/url" "regexp" "runtime" "strings" "syscall" "time" "unicode/utf8" "github.com/gdgvda/cron" "github.com/gofrs/uuid/v5" "github.com/jmoiron/sqlx/types" koanfjson "github.com/knadh/koanf/parsers/json" "github.com/knadh/koanf/providers/rawbytes" "github.com/knadh/koanf/v2" "github.com/knadh/listmonk/internal/auth" "github.com/knadh/listmonk/internal/messenger/email" "github.com/knadh/listmonk/internal/notifs" "github.com/knadh/listmonk/models" "github.com/labstack/echo/v4" ) const pwdMask = "•" type aboutHost struct { OS string `json:"os"` Machine string `json:"arch"` Hostname string `json:"hostname"` } type aboutSystem struct { NumCPU int `json:"num_cpu"` AllocMB uint64 `json:"memory_alloc_mb"` OSMB uint64 `json:"memory_from_os_mb"` } type about struct { Version string `json:"version"` Build string `json:"build"` GoVersion string `json:"go_version"` GoArch string `json:"go_arch"` Database types.JSONText `json:"database"` System aboutSystem `json:"system"` Host aboutHost `json:"host"` } var ( reAlphaNum = regexp.MustCompile(`[^a-z0-9\-]`) ) // GetSettings returns settings from the DB. func (a *App) GetSettings(c echo.Context) error { s, err := a.core.GetSettings() if err != nil { return err } // Empty out passwords. for i := range s.SMTP { s.SMTP[i].Password = strings.Repeat(pwdMask, utf8.RuneCountInString(s.SMTP[i].Password)) } for i := range s.BounceBoxes { s.BounceBoxes[i].Password = strings.Repeat(pwdMask, utf8.RuneCountInString(s.BounceBoxes[i].Password)) } for i := range s.Messengers { s.Messengers[i].Password = strings.Repeat(pwdMask, utf8.RuneCountInString(s.Messengers[i].Password)) } s.UploadS3AwsSecretAccessKey = strings.Repeat(pwdMask, utf8.RuneCountInString(s.UploadS3AwsSecretAccessKey)) s.SendgridKey = strings.Repeat(pwdMask, utf8.RuneCountInString(s.SendgridKey)) s.BouncePostmark.Password = strings.Repeat(pwdMask, utf8.RuneCountInString(s.BouncePostmark.Password)) s.BounceForwardEmail.Key = strings.Repeat(pwdMask, utf8.RuneCountInString(s.BounceForwardEmail.Key)) s.SecurityCaptcha.HCaptcha.Secret = strings.Repeat(pwdMask, utf8.RuneCountInString(s.SecurityCaptcha.HCaptcha.Secret)) s.OIDC.ClientSecret = strings.Repeat(pwdMask, utf8.RuneCountInString(s.OIDC.ClientSecret)) return c.JSON(http.StatusOK, okResp{s}) } // UpdateSettings returns settings from the DB. func (a *App) UpdateSettings(c echo.Context) error { // Unmarshal and marshal the fields once to sanitize the settings blob. var set models.Settings if err := c.Bind(&set); err != nil { return err } // Get the existing settings. cur, err := a.core.GetSettings() if err != nil { return err } // Validate and sanitize postback Messenger names along with SMTP names // (where each SMTP is also considered as a standalone messenger). // Duplicates are disallowed and "email" is a reserved name. names := map[string]bool{emailMsgr: true} // There should be at least one SMTP block that's enabled. has := false for i, s := range set.SMTP { if s.Enabled { has = true } // Sanitize and normalize the SMTP server name. name := reAlphaNum.ReplaceAllString(strings.ToLower(strings.TrimSpace(s.Name)), "-") if name != "" { if !strings.HasPrefix(name, "email-") { name = "email-" + name } if _, ok := names[name]; ok { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("settings.duplicateMessengerName", "name", name)) } names[name] = true } set.SMTP[i].Name = name // Assign a UUID. The frontend only sends a password when the user explicitly // changes the password. In other cases, the existing password in the DB // is copied while updating the settings and the UUID is used to match // the incoming array of SMTP blocks with the array in the DB. if s.UUID == "" { set.SMTP[i].UUID = uuid.Must(uuid.NewV4()).String() } // Ensure the HOST is trimmed of any whitespace. // This is a common mistake when copy-pasting SMTP settings. set.SMTP[i].Host = strings.TrimSpace(s.Host) // If there's no password coming in from the frontend, copy the existing // password by matching the UUID. if s.Password == "" { for _, c := range cur.SMTP { if s.UUID == c.UUID { set.SMTP[i].Password = c.Password } } } } if !has { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("settings.errorNoSMTP")) } // Always remove the trailing slash from the app root URL. set.AppRootURL = strings.TrimRight(set.AppRootURL, "/") // Bounce boxes. for i, s := range set.BounceBoxes { // Assign a UUID. The frontend only sends a password when the user explicitly // changes the password. In other cases, the existing password in the DB // is copied while updating the settings and the UUID is used to match // the incoming array of blocks with the array in the DB. if s.UUID == "" { set.BounceBoxes[i].UUID = uuid.Must(uuid.NewV4()).String() } // Ensure the HOST is trimmed of any whitespace. // This is a common mistake when copy-pasting SMTP settings. set.BounceBoxes[i].Host = strings.TrimSpace(s.Host) if d, _ := time.ParseDuration(s.ScanInterval); d.Minutes() < 1 { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("settings.bounces.invalidScanInterval")) } // If there's no password coming in from the frontend, copy the existing // password by matching the UUID. if s.Password == "" { for _, c := range cur.BounceBoxes { if s.UUID == c.UUID { set.BounceBoxes[i].Password = c.Password } } } } for i, m := range set.Messengers { // UUID to keep track of password changes similar to the SMTP logic above. if m.UUID == "" { set.Messengers[i].UUID = uuid.Must(uuid.NewV4()).String() } if m.Password == "" { for _, c := range cur.Messengers { if m.UUID == c.UUID { set.Messengers[i].Password = c.Password } } } name := reAlphaNum.ReplaceAllString(strings.ToLower(m.Name), "") if _, ok := names[name]; ok { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("settings.duplicateMessengerName", "name", name)) } if len(name) == 0 { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("settings.invalidMessengerName")) } set.Messengers[i].Name = name names[name] = true } // S3 password? if set.UploadS3AwsSecretAccessKey == "" { set.UploadS3AwsSecretAccessKey = cur.UploadS3AwsSecretAccessKey } if set.SendgridKey == "" { set.SendgridKey = cur.SendgridKey } if set.BouncePostmark.Password == "" { set.BouncePostmark.Password = cur.BouncePostmark.Password } if set.BounceForwardEmail.Key == "" { set.BounceForwardEmail.Key = cur.BounceForwardEmail.Key } if set.SecurityCaptcha.HCaptcha.Secret == "" { set.SecurityCaptcha.HCaptcha.Secret = cur.SecurityCaptcha.HCaptcha.Secret } if set.OIDC.ClientSecret == "" { set.OIDC.ClientSecret = cur.OIDC.ClientSecret } // OIDC user auto-creation is enabled. Validate. if set.OIDC.AutoCreateUsers { if set.OIDC.DefaultUserRoleID.Int < auth.SuperAdminRoleID { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", a.i18n.T("settings.security.OIDCDefaultRole"))) } } for n, v := range set.UploadExtensions { set.UploadExtensions[n] = strings.ToLower(strings.TrimPrefix(strings.TrimSpace(v), ".")) } // Domain blocklist / allowlist. doms := make([]string, 0, len(set.DomainBlocklist)) for _, d := range set.DomainBlocklist { if d = strings.TrimSpace(strings.ToLower(d)); d != "" { doms = append(doms, d) } } set.DomainBlocklist = doms doms = make([]string, 0, len(set.DomainAllowlist)) for _, d := range set.DomainAllowlist { if d = strings.TrimSpace(strings.ToLower(d)); d != "" { doms = append(doms, d) } } set.DomainAllowlist = doms // Validate and clean CORS domains. cors := make([]string, 0, len(set.SecurityCORSOrigins)) for _, d := range set.SecurityCORSOrigins { if d = strings.TrimSpace(d); d != "" { if d == "*" { cors = append(cors, d) continue } // Parse and validate the URL. u, err := url.Parse(d) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidData")+": invalid CORS domain: "+d) } // Save clean scheme + host cors = append(cors, u.Scheme+"://"+u.Host) } } set.SecurityCORSOrigins = cors // Validate slow query caching cron. if set.CacheSlowQueries { if _, err := cron.ParseStandard(set.CacheSlowQueriesInterval); err != nil { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidData")+": slow query cron: "+err.Error()) } } // Update the settings in the DB. if err := a.core.UpdateSettings(set); err != nil { return err } return a.handleSettingsRestart(c) } // UpdateSettingsByKey updates a single setting key-value in the DB. func (a *App) UpdateSettingsByKey(c echo.Context) error { key := c.Param("key") if key == "" { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("globals.messages.invalidData")) } // Read the raw JSON body as the value. var b json.RawMessage if err := c.Bind(&b); err != nil { return err } // Update the value in the DB. if err := a.core.UpdateSettingsByKey(key, b); err != nil { return err } return a.handleSettingsRestart(c) } // handleSettingsRestart checks for running campaigns and either triggers an // immediate app restart or marks the app as needing a restart. func (a *App) handleSettingsRestart(c echo.Context) error { // If there are any active campaigns, don't do an auto reload and // warn the user on the frontend. if a.manager.HasRunningCampaigns() { a.Lock() a.needsRestart = true a.Unlock() return c.JSON(http.StatusOK, okResp{struct { NeedsRestart bool `json:"needs_restart"` }{true}}) } // No running campaigns. Reload the app. go func() { <-time.After(time.Millisecond * 500) a.chReload <- syscall.SIGHUP }() return c.JSON(http.StatusOK, okResp{true}) } // GetLogs returns the log entries stored in the log buffer. func (a *App) GetLogs(c echo.Context) error { return c.JSON(http.StatusOK, okResp{a.bufLog.Lines()}) } // TestSMTPSettings returns the log entries stored in the log buffer. func (a *App) TestSMTPSettings(c echo.Context) error { // Copy the raw JSON post body. reqBody, err := io.ReadAll(c.Request().Body) if err != nil { a.log.Printf("error reading SMTP test: %v", err) return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.internalError")) } // Load the JSON into koanf to parse SMTP settings properly including timestrings. ko := koanf.New(".") if err := ko.Load(rawbytes.Provider(reqBody), koanfjson.Parser()); err != nil { a.log.Printf("error unmarshalling SMTP test request: %v", err) return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.internalError")) } req := email.Server{} if err := ko.UnmarshalWithConf("", &req, koanf.UnmarshalConf{Tag: "json"}); err != nil { a.log.Printf("error scanning SMTP test request: %v", err) return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.internalError")) } to := ko.String("email") if to == "" { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.missingFields", "name", "email")) } // Initialize a new SMTP pool. req.MaxConns = 1 req.IdleTimeout = time.Second * 2 req.PoolWaitTimeout = time.Second * 2 msgr, err := email.New("", req) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.errorCreating", "name", "SMTP", "error", err.Error())) } // Render the test email template body. var b bytes.Buffer if err := notifs.Tpls.ExecuteTemplate(&b, "smtp-test", nil); err != nil { a.log.Printf("error compiling notification template '%s': %v", "smtp-test", err) return err } m := models.Message{} m.From = a.cfg.FromEmail m.To = []string{to} m.Subject = a.i18n.T("settings.smtp.testConnection") m.Body = b.Bytes() if err := msgr.Push(m); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.JSON(http.StatusOK, okResp{a.bufLog.Lines()}) } func (a *App) GetAboutInfo(c echo.Context) error { var mem runtime.MemStats runtime.ReadMemStats(&mem) out := a.about out.System.AllocMB = mem.Alloc / 1024 / 1024 out.System.OSMB = mem.Sys / 1024 / 1024 return c.JSON(http.StatusOK, out) } ================================================ FILE: cmd/subscribers.go ================================================ package main import ( "encoding/csv" "encoding/json" "fmt" "net/http" "net/textproto" "net/url" "strconv" "strings" "github.com/knadh/listmonk/internal/auth" "github.com/knadh/listmonk/internal/i18n" "github.com/knadh/listmonk/internal/notifs" "github.com/knadh/listmonk/internal/subimporter" "github.com/knadh/listmonk/models" "github.com/labstack/echo/v4" "github.com/lib/pq" ) const ( dummyUUID = "00000000-0000-0000-0000-000000000000" ) // subQueryReq is a "catch all" struct for reading various // subscriber related requests. type subQueryReq struct { Search string `json:"search"` Query string `json:"query"` ListIDs []int `json:"list_ids"` TargetListIDs []int `json:"target_list_ids"` SubscriberIDs []int `json:"ids"` Action string `json:"action"` Status string `json:"status"` SubscriptionStatus string `json:"subscription_status"` All bool `json:"all"` } // subOptin contains the data that's passed to the double opt-in e-mail template. type subOptin struct { models.Subscriber OptinURL string UnsubURL string Lists []models.List } var ( dummySubscriber = models.Subscriber{ Email: "demo@listmonk.app", Name: "Demo Subscriber", UUID: dummyUUID, Attribs: models.JSON{"city": "Bengaluru"}, } ) // GetSubscriber handles the retrieval of a single subscriber by ID. func (a *App) GetSubscriber(c echo.Context) error { user := auth.GetUser(c) // Check if the user has access to at least one of the lists on the subscriber. id := getID(c) if err := a.hasSubPerm(user, []int{id}); err != nil { return err } // Fetch the subscriber from the DB. out, err := a.core.GetSubscriber(id, "", "") if err != nil { return err } return c.JSON(http.StatusOK, okResp{out}) } // GetSubscriberActivity handles the retrieval of a subscriber's campaign views and link clicks. func (a *App) GetSubscriberActivity(c echo.Context) error { user := auth.GetUser(c) // Check if the user has access to at least one of the lists on the subscriber. id := getID(c) if err := a.hasSubPerm(user, []int{id}); err != nil { return err } // Fetch the subscriber activity from the DB. out, err := a.core.GetSubscriberActivity(id) if err != nil { return err } return c.JSON(http.StatusOK, okResp{out}) } // QuerySubscribers handles querying subscribers based on an arbitrary SQL expression. func (a *App) QuerySubscribers(c echo.Context) error { // Get the authenticated user. user := auth.GetUser(c) // Filter list IDs by permission. listIDs, err := a.filterListQueryByPerm("list_id", c.QueryParams(), user) if err != nil { return err } // Does the user have the subscribers:sql_query permission? query := formatSQLExp(c.FormValue("query")) if query != "" { if !user.HasPerm(auth.PermSubscribersSqlQuery) { return echo.NewHTTPError(http.StatusForbidden, a.i18n.Ts("globals.messages.permissionDenied", "name", auth.PermSubscribersSqlQuery)) } } var ( searchStr = strings.TrimSpace(c.FormValue("search")) subStatus = c.FormValue("subscription_status") order = c.FormValue("order") orderBy = c.FormValue("order_by") pg = a.pg.NewFromURL(c.Request().URL.Query()) ) // Query subscribers from the DB. res, total, err := a.core.QuerySubscribers(searchStr, query, listIDs, subStatus, order, orderBy, pg.Offset, pg.Limit) if err != nil { return err } out := models.PageResults{ Query: query, Search: searchStr, Results: res, Total: total, Page: pg.Page, PerPage: pg.PerPage, } return c.JSON(http.StatusOK, okResp{out}) } // ExportSubscribers handles querying subscribers based on an arbitrary SQL expression. func (a *App) ExportSubscribers(c echo.Context) error { // Get the authenticated user. user := auth.GetUser(c) // Filter list IDs by permission. listIDs, err := a.filterListQueryByPerm("list_id", c.QueryParams(), user) if err != nil { return err } // Export only specific subscriber IDs? subIDs, err := getQueryInts("id", c.QueryParams()) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("globals.messages.invalidID")) } // Filter by subscription status subStatus := c.QueryParam("subscription_status") // Does the user have the subscribers:sql_query permission? var ( searchStr = strings.TrimSpace(c.FormValue("search")) query = formatSQLExp(c.FormValue("query")) ) if query != "" { if !user.HasPerm(auth.PermSubscribersSqlQuery) { return echo.NewHTTPError(http.StatusForbidden, a.i18n.Ts("globals.messages.permissionDenied", "name", auth.PermSubscribersSqlQuery)) } } // Get the batched export iterator. exp, err := a.core.ExportSubscribers(searchStr, query, subIDs, listIDs, subStatus, a.cfg.DBBatchSize) if err != nil { return err } var ( hdr = c.Response().Header() wr = csv.NewWriter(c.Response()) ) hdr.Set(echo.HeaderContentType, echo.MIMEOctetStream) hdr.Set("Content-type", "text/csv") hdr.Set(echo.HeaderContentDisposition, "attachment; filename="+"subscribers.csv") hdr.Set("Content-Transfer-Encoding", "binary") hdr.Set("Cache-Control", "no-cache") wr.Write([]string{"uuid", "email", "name", "attributes", "status", "created_at", "updated_at"}) loop: // Iterate in batches until there are no more subscribers to export. for { out, err := exp() if err != nil { return err } if len(out) == 0 { break } for _, r := range out { if err = wr.Write([]string{r.UUID, r.Email, r.Name, r.Attribs, r.Status, r.CreatedAt.Time.String(), r.UpdatedAt.Time.String()}); err != nil { a.log.Printf("error streaming CSV export: %v", err) break loop } } // Flush CSV to stream after each batch. wr.Flush() } return nil } // CreateSubscriber handles the creation of a new subscriber. func (a *App) CreateSubscriber(c echo.Context) error { // Get the authenticated user. user := auth.GetUser(c) // Get and validate fields. var req subimporter.SubReq if err := c.Bind(&req); err != nil { return err } // Validate fields. req, err := a.importer.ValidateFields(req) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } // Filter lists against the current user's permitted lists. listIDs := user.FilterListsByPerm(auth.PermTypeManage, req.Lists) // Not a single permitted list? if len(req.Lists) > 0 && len(listIDs) == 0 { return echo.NewHTTPError(http.StatusForbidden, a.i18n.Ts("globals.messages.permissionDenied", "name", "lists")) } // Insert the subscriber into the DB. sub, _, err := a.core.InsertSubscriber(req.Subscriber, listIDs, nil, req.PreconfirmSubs, false) if err != nil { return err } return c.JSON(http.StatusOK, okResp{sub}) } // UpdateSubscriber handles modification of a subscriber. func (a *App) UpdateSubscriber(c echo.Context) error { // Get the authenticated user. user := auth.GetUser(c) // Get and validate fields. req := struct { models.Subscriber Lists []int `json:"lists"` PreconfirmSubs bool `json:"preconfirm_subscriptions"` }{} if err := c.Bind(&req); err != nil { return err } // Sanitize and validate the email field. if em, err := a.importer.SanitizeEmail(req.Email); err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } else { req.Email = em } if req.Name != "" && !strHasLen(req.Name, 1, stdInputMaxLen) { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("subscribers.invalidName")) } // Filter lists against the current user's permitted lists. listIDs := user.FilterListsByPerm(auth.PermTypeManage, req.Lists) // Not a single permitted list? if len(req.Lists) > 0 && len(listIDs) == 0 { return echo.NewHTTPError(http.StatusForbidden, a.i18n.Ts("globals.messages.permissionDenied", "name", "lists")) } // Update the subscriber in the DB. id := getID(c) // Get the user's permitted lists to pass to the update query so that lists on the subscribers // to which they don't have permissions are preserved/left as-is when deleteLists=true. allPerm, permittedLists := user.GetPermittedLists(auth.PermTypeManage) if allPerm { permittedLists = []int{} } out, _, err := a.core.UpdateSubscriberWithLists(id, req.Subscriber, listIDs, nil, req.PreconfirmSubs, true, false, permittedLists) if err != nil { return err } return c.JSON(http.StatusOK, okResp{out}) } // SubscriberSendOptin sends an optin confirmation e-mail to a subscriber. func (a *App) SubscriberSendOptin(c echo.Context) error { // Fetch the subscriber. id := getID(c) out, err := a.core.GetSubscriber(id, "", "") if err != nil { return err } // Trigger the opt-in confirmation e-mail hook. if _, err := a.fnOptinNotify(out, nil); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("subscribers.errorSendingOptin")) } return c.JSON(http.StatusOK, okResp{true}) } // BlocklistSubscriber handles the blocklisting of a given subscriber. func (a *App) BlocklistSubscriber(c echo.Context) error { // Update the subscribers in the DB. id := getID(c) if err := a.core.BlocklistSubscribers([]int{id}); err != nil { return err } return c.JSON(http.StatusOK, okResp{true}) } // BlocklistSubscribers handles the blocklisting of one or more subscribers. func (a *App) BlocklistSubscribers(c echo.Context) error { var req subQueryReq if err := c.Bind(&req); err != nil { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error())) } if len(req.SubscriberIDs) == 0 { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.errorInvalidIDs", "error", "ids")) } // Update the subscribers in the DB. if err := a.core.BlocklistSubscribers(req.SubscriberIDs); err != nil { return err } return c.JSON(http.StatusOK, okResp{true}) } // ManageSubscriberLists handles bulk addition or removal of subscribers // from or to one or more target lists. // It takes either an ID in the URI, or a list of IDs in the request body. func (a *App) ManageSubscriberLists(c echo.Context) error { // Get the authenticated user. user := auth.GetUser(c) // Is it an /:id call? var ( pID = c.Param("id") subIDs []int ) if pID != "" { id, _ := strconv.Atoi(pID) if id < 1 { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("globals.messages.invalidID")) } subIDs = append(subIDs, id) } var req subQueryReq if err := c.Bind(&req); err != nil { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error())) } if len(req.SubscriberIDs) == 0 { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("subscribers.errorNoIDs")) } if len(subIDs) == 0 { subIDs = req.SubscriberIDs } if len(req.TargetListIDs) == 0 { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("subscribers.errorNoListsGiven")) } // Filter lists against the current user's permitted lists. listIDs := user.FilterListsByPerm(auth.PermTypeGet|auth.PermTypeManage, req.TargetListIDs) // User doesn't have the required list permissions. if len(listIDs) == 0 { return echo.NewHTTPError(http.StatusForbidden, a.i18n.Ts("globals.messages.permissionDenied", "name", "lists")) } // Run the action in the DB. var err error switch req.Action { case "add": err = a.core.AddSubscriptions(subIDs, listIDs, req.Status) case "remove": err = a.core.DeleteSubscriptions(subIDs, listIDs) case "unsubscribe": err = a.core.UnsubscribeLists(subIDs, listIDs, nil) default: return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("subscribers.invalidAction")) } if err != nil { return err } return c.JSON(http.StatusOK, okResp{true}) } // DeleteSubscriber handles deletion of a single subscriber. func (a *App) DeleteSubscriber(c echo.Context) error { // Delete the subscribers from the DB. id := getID(c) if err := a.core.DeleteSubscribers([]int{id}, nil); err != nil { return err } return c.JSON(http.StatusOK, okResp{true}) } // DeleteSubscribers handles bulk deletion of one or more subscribers. func (a *App) DeleteSubscribers(c echo.Context) error { // Multiple IDs. ids, err := parseStringIDs(c.Request().URL.Query()["id"]) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error())) } if len(ids) == 0 { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.errorInvalidIDs", "error", "ids")) } // Delete the subscribers from the DB. if err := a.core.DeleteSubscribers(ids, nil); err != nil { return err } return c.JSON(http.StatusOK, okResp{true}) } // DeleteSubscribersByQuery bulk deletes based on an // arbitrary SQL expression. func (a *App) DeleteSubscribersByQuery(c echo.Context) error { // Get the authenticated user. user := auth.GetUser(c) var req subQueryReq if err := c.Bind(&req); err != nil { return err } req.Search = strings.TrimSpace(req.Search) req.Query = formatSQLExp(req.Query) if req.All { // If the "all" flag is set, ignore any subquery that may be present. req.Search = "" req.Query = "" } else if req.Search == "" && req.Query == "" { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "query")) } // Does the user have the subscribers:sql_query permission? if req.Query != "" { if !user.HasPerm(auth.PermSubscribersSqlQuery) { return echo.NewHTTPError(http.StatusForbidden, a.i18n.Ts("globals.messages.permissionDenied", "name", auth.PermSubscribersSqlQuery)) } } // Delete the subscribers from the DB. if err := a.core.DeleteSubscribersByQuery(req.Search, req.Query, req.ListIDs, req.SubscriptionStatus); err != nil { return err } return c.JSON(http.StatusOK, okResp{true}) } // BlocklistSubscribersByQuery bulk blocklists subscribers // based on an arbitrary SQL expression. func (a *App) BlocklistSubscribersByQuery(c echo.Context) error { // Get the authenticated user. user := auth.GetUser(c) var req subQueryReq if err := c.Bind(&req); err != nil { return err } req.Search = strings.TrimSpace(req.Search) req.Query = formatSQLExp(req.Query) if req.All { // If the "all" flag is set, ignore any subquery that may be present. req.Search = "" req.Query = "" } else if req.Search == "" && req.Query == "" { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "query")) } // Does the user have the subscribers:sql_query permission? if req.Query != "" { if !user.HasPerm(auth.PermSubscribersSqlQuery) { return echo.NewHTTPError(http.StatusForbidden, a.i18n.Ts("globals.messages.permissionDenied", "name", auth.PermSubscribersSqlQuery)) } } // Update the subscribers in the DB. if err := a.core.BlocklistSubscribersByQuery(req.Search, req.Query, req.ListIDs, req.SubscriptionStatus); err != nil { return err } return c.JSON(http.StatusOK, okResp{true}) } // ManageSubscriberListsByQuery bulk adds/removes/unsubscribes subscribers // from one or more lists based on an arbitrary SQL expression. func (a *App) ManageSubscriberListsByQuery(c echo.Context) error { // Get the authenticated user. user := auth.GetUser(c) var req subQueryReq if err := c.Bind(&req); err != nil { return err } if len(req.TargetListIDs) == 0 { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("subscribers.errorNoListsGiven")) } req.Search = strings.TrimSpace(req.Search) req.Query = formatSQLExp(req.Query) // Does the user have the subscribers:sql_query permission? if req.Query != "" { if !user.HasPerm(auth.PermSubscribersSqlQuery) { return echo.NewHTTPError(http.StatusForbidden, a.i18n.Ts("globals.messages.permissionDenied", "name", auth.PermSubscribersSqlQuery)) } } // Filter lists against the current user's permitted lists. sourceListIDs := user.FilterListsByPerm(auth.PermTypeGet|auth.PermTypeManage, req.ListIDs) targetListIDs := user.FilterListsByPerm(auth.PermTypeGet|auth.PermTypeManage, req.TargetListIDs) // Run the action in the DB. var err error switch req.Action { case "add": err = a.core.AddSubscriptionsByQuery(req.Search, req.Query, sourceListIDs, targetListIDs, req.Status, req.SubscriptionStatus) case "remove": err = a.core.DeleteSubscriptionsByQuery(req.Search, req.Query, sourceListIDs, targetListIDs, req.SubscriptionStatus) case "unsubscribe": err = a.core.UnsubscribeListsByQuery(req.Search, req.Query, sourceListIDs, targetListIDs, req.SubscriptionStatus) default: return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("subscribers.invalidAction")) } if err != nil { return err } return c.JSON(http.StatusOK, okResp{true}) } // DeleteSubscriberBounces deletes all the bounces on a subscriber. func (a *App) DeleteSubscriberBounces(c echo.Context) error { // Delete the bounces from the DB. id := getID(c) if err := a.core.DeleteSubscriberBounces(id, ""); err != nil { return err } return c.JSON(http.StatusOK, okResp{true}) } // ExportSubscriberData pulls the subscriber's profile, // list subscriptions, campaign views and clicks and produces // a JSON report. This is a privacy feature and depends on the // configuration in a.Constants.Privacy. func (a *App) ExportSubscriberData(c echo.Context) error { // Get the subscriber's data. A single query that gets the profile, // list subscriptions, campaign views, and link clicks. Names of // private lists are replaced with "Private list". id := getID(c) _, b, err := a.exportSubscriberData(id, "", a.cfg.Privacy.Exportable) if err != nil { a.log.Printf("error exporting subscriber data: %s", err) return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", err.Error())) } // Set headers to force the browser to prompt for download. c.Response().Header().Set("Cache-Control", "no-cache") c.Response().Header().Set("Content-Disposition", `attachment; filename="data.json"`) return c.Blob(http.StatusOK, "application/json", b) } // exportSubscriberData collates the data of a subscriber including profile, // subscriptions, campaign_views, link_clicks (if they're enabled in the config) // and returns a formatted, indented JSON payload. Either takes a numeric id // and an empty subUUID or takes 0 and a string subUUID. func (a *App) exportSubscriberData(id int, subUUID string, exportables map[string]bool) (models.SubscriberExportProfile, []byte, error) { data, err := a.core.GetSubscriberProfileForExport(id, subUUID) if err != nil { return data, nil, err } // Filter out the non-exportable items. if _, ok := exportables["profile"]; !ok { data.Profile = nil } if _, ok := exportables["subscriptions"]; !ok { data.Subscriptions = nil } if _, ok := exportables["campaign_views"]; !ok { data.CampaignViews = nil } if _, ok := exportables["link_clicks"]; !ok { data.LinkClicks = nil } // Marshal the data into an indented payload. b, err := json.MarshalIndent(data, "", " ") if err != nil { a.log.Printf("error marshalling subscriber export data: %v", err) return data, nil, err } return data, b, nil } // hasSubPerm checks whether the current user has permission to access the given list // of subscriber IDs. func (a *App) hasSubPerm(u auth.User, subIDs []int) error { allPerm, listIDs := u.GetPermittedLists(auth.PermTypeGet | auth.PermTypeManage) // User has blanket get_all|manage_all permission. if allPerm { return nil } // Check whether the subscribers have the list IDs permitted to the user. res, err := a.core.HasSubscriberLists(subIDs, listIDs) if err != nil { return err } for id, has := range res { if !has { return echo.NewHTTPError(http.StatusForbidden, a.i18n.Ts("globals.messages.permissionDenied", "name", fmt.Sprintf("subscriber: %d", id))) } } return nil } // filterListQueryByPerm filters the list IDs in the query params and returns the list IDs to which the user has access. func (a *App) filterListQueryByPerm(param string, qp url.Values, user auth.User) ([]int, error) { var listIDs []int // If there are incoming list query params, filter them by permission. if qp.Has(param) { ids, err := getQueryInts(param, qp) if err != nil { return nil, echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("globals.messages.invalidID")) } listIDs = user.FilterListsByPerm(auth.PermTypeGet|auth.PermTypeManage, ids) } // There are no incoming params. If the user doesn't have permission to get all subscribers, // filter by the lists they have access to. if len(listIDs) == 0 { if _, ok := user.PermissionsMap[auth.PermSubscribersGetAll]; !ok { if len(user.GetListIDs) > 0 { listIDs = user.GetListIDs } else { // User doesn't have access to any lists. listIDs = []int{-1} } } } return listIDs, nil } // formatSQLExp does basic sanitisation on arbitrary // SQL query expressions coming from the frontend. func formatSQLExp(q string) string { q = strings.TrimSpace(q) if len(q) == 0 { return "" } // Remove semicolon suffix. if q[len(q)-1] == ';' { q = q[:len(q)-1] } return q } // makeOptinNotifyHook returns an enclosed callback that sends optin confirmation e-mails. // This is plugged into the 'core' package to send optin confirmations when a new subscriber is // created via `core.CreateSubscriber()`. func makeOptinNotifyHook(unsubHeader bool, u *UrlConfig, q *models.Queries, i *i18n.I18n) func(sub models.Subscriber, listIDs []int) (int, error) { return func(sub models.Subscriber, listIDs []int) (int, error) { // Fetch double opt-in lists from the given list IDs. // Get the list of subscription lists where the subscriber hasn't confirmed. var lists = []models.List{} if err := q.GetSubscriberLists.Select(&lists, sub.ID, nil, pq.Array(listIDs), nil, models.SubscriptionStatusUnconfirmed, models.ListOptinDouble); err != nil { lo.Printf("error fetching lists for opt-in: %s", err) return 0, err } // None. if len(lists) == 0 { return 0, nil } var ( out = subOptin{Subscriber: sub, Lists: lists} qListIDs = url.Values{} ) // Construct the opt-in URL with list IDs. for _, l := range out.Lists { qListIDs.Add("l", l.UUID) } out.OptinURL = fmt.Sprintf(u.OptinURL, sub.UUID, qListIDs.Encode()) out.UnsubURL = fmt.Sprintf(u.UnsubURL, dummyUUID, sub.UUID) // Unsub headers. hdr := textproto.MIMEHeader{} hdr.Set(models.EmailHeaderSubscriberUUID, sub.UUID) // Attach List-Unsubscribe headers? if unsubHeader { unsubURL := fmt.Sprintf(u.UnsubURL, dummyUUID, sub.UUID) hdr.Set("List-Unsubscribe-Post", "List-Unsubscribe=One-Click") hdr.Set("List-Unsubscribe", `<`+unsubURL+`>`) } // Send the e-mail. if err := notifs.Notify([]string{sub.Email}, i.T("subscribers.optinSubject"), notifs.TplSubscriberOptin, out, hdr); err != nil { lo.Printf("error sending opt-in e-mail for subscriber %d (%s): %s", sub.ID, sub.UUID, err) return 0, err } return len(lists), nil } } ================================================ FILE: cmd/templates.go ================================================ package main import ( "errors" "html/template" "net/http" "regexp" "strconv" "strings" "github.com/knadh/listmonk/models" "github.com/labstack/echo/v4" ) const ( // tplTag is the template tag that should be present in a template // as the placeholder for campaign bodies. tplTag = `{{ template "content" . }}` dummyTpl = `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.
` ) var ( regexpTplTag = regexp.MustCompile(`{{(\s+)?template\s+?"content"(\s+)?\.(\s+)?}}`) ) // GetTemplate handles the retrieval of a template func (a *App) GetTemplate(c echo.Context) error { // If no_body is true, blank out the body of the template from the response. noBody, _ := strconv.ParseBool(c.QueryParam("no_body")) // Get the template from the DB. id := getID(c) out, err := a.core.GetTemplate(id, noBody) if err != nil { return err } return c.JSON(http.StatusOK, okResp{out}) } // GetTemplates handles retrieval of templates. func (a *App) GetTemplates(c echo.Context) error { // If no_body is true, blank out the body of the template from the response. noBody, _ := strconv.ParseBool(c.QueryParam("no_body")) // Fetch templates from the DB. out, err := a.core.GetTemplates("", noBody) if err != nil { return err } return c.JSON(http.StatusOK, okResp{out}) } // PreviewTemplate renders the HTML preview of a template in the DB. func (a *App) PreviewTemplate(c echo.Context) error { // Fetch one template from the DB. id := getID(c) tpl, err := a.core.GetTemplate(id, false) if err != nil { return err } // Render the template. out, err := a.previewTemplate(tpl) if err != nil { return err } return c.HTML(http.StatusOK, string(out)) } // PreviewTemplateBody renders the HTML preview of a template given its type and body. func (a *App) PreviewTemplateBody(c echo.Context) error { tpl := models.Template{ Type: c.FormValue("template_type"), Body: c.FormValue("body"), } // Body is posted with the request. if tpl.Type == "" { tpl.Type = models.TemplateTypeCampaign } if tpl.Type == models.TemplateTypeCampaign && !regexpTplTag.MatchString(tpl.Body) { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("templates.placeholderHelp", "placeholder", tplTag)) } // Render the template. out, err := a.previewTemplate(tpl) if err != nil { return err } return c.HTML(http.StatusOK, string(out)) } // CreateTemplate handles template creation. func (a *App) CreateTemplate(c echo.Context) error { var o models.Template if err := c.Bind(&o); err != nil { return err } if err := a.validateTemplate(o); err != nil { return err } // Subject is only relevant for fixed tx templates. For campaigns, // the subject changes per campaign and is on models.Campaign. var funcs template.FuncMap if o.Type == models.TemplateTypeCampaign || o.Type == models.TemplateTypeCampaignVisual { o.Subject = "" funcs = a.manager.TemplateFuncs(nil) } else { funcs = a.manager.GenericTemplateFuncs() } // Compile the template and validate. if err := o.Compile(funcs); err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } // Create the template the in the DB. out, err := a.core.CreateTemplate(o.Name, o.Type, o.Subject, []byte(o.Body), o.BodySource) if err != nil { return err } // If it's a transactional template, cache it in the manager // to be used for arbitrary incoming tx message pushes. if o.Type == models.TemplateTypeTx { a.manager.CacheTpl(out.ID, &o) } return c.JSON(http.StatusOK, okResp{out}) } // UpdateTemplate handles template modification. func (a *App) UpdateTemplate(c echo.Context) error { var o models.Template if err := c.Bind(&o); err != nil { return err } if err := a.validateTemplate(o); err != nil { return err } // Subject is only relevant for fixed tx templates. For campaigns, // the subject changes per campaign and is on models.Campaign. var funcs template.FuncMap if o.Type == models.TemplateTypeCampaign || o.Type == models.TemplateTypeCampaignVisual { o.Subject = "" funcs = a.manager.TemplateFuncs(nil) } else { funcs = a.manager.GenericTemplateFuncs() } // Compile the template and validate. if err := o.Compile(funcs); err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } // Update the template in the DB. id := getID(c) out, err := a.core.UpdateTemplate(id, o.Name, o.Subject, []byte(o.Body), o.BodySource) if err != nil { return err } // If it's a transactional template, cache it. if out.Type == models.TemplateTypeTx { a.manager.CacheTpl(out.ID, &o) } return c.JSON(http.StatusOK, okResp{out}) } // TemplateSetDefault handles template modification. func (a *App) TemplateSetDefault(c echo.Context) error { // Update the template in the DB. id := getID(c) if err := a.core.SetDefaultTemplate(id); err != nil { return err } return a.GetTemplates(c) } // DeleteTemplate handles template deletion. func (a *App) DeleteTemplate(c echo.Context) error { // Delete the template from the DB. id := getID(c) if err := a.core.DeleteTemplate(id); err != nil { return err } // Delete cached in-memory template. a.manager.DeleteTpl(id) return c.JSON(http.StatusOK, okResp{true}) } // compileTemplate validates template fields. func (a *App) validateTemplate(o models.Template) error { if !strHasLen(o.Name, 1, stdInputMaxLen) { return errors.New(a.i18n.T("campaigns.fieldInvalidName")) } if o.Type == models.TemplateTypeCampaign && !regexpTplTag.MatchString(o.Body) { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("templates.placeholderHelp", "placeholder", tplTag)) } if o.Type == models.TemplateTypeTx && strings.TrimSpace(o.Subject) == "" { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.missingFields", "name", "subject")) } return nil } // previewTemplate renders the HTML preview of a template. func (a *App) previewTemplate(tpl models.Template) ([]byte, error) { var out []byte if tpl.Type == models.TemplateTypeCampaign || tpl.Type == models.TemplateTypeCampaignVisual { camp := models.Campaign{ UUID: dummyUUID, Name: a.i18n.T("templates.dummyName"), Subject: a.i18n.T("templates.dummySubject"), FromEmail: "dummy-campaign@listmonk.app", TemplateBody: tpl.Body, Body: dummyTpl, } if err := camp.CompileTemplate(a.manager.TemplateFuncs(&camp)); err != nil { return nil, echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("templates.errorCompiling", "error", err.Error())) } // Render the message body. msg, err := a.manager.NewCampaignMessage(&camp, dummySubscriber) if err != nil { return nil, echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("templates.errorRendering", "error", err.Error())) } out = msg.Body() } else { // Compile transactional template. if err := tpl.Compile(a.manager.GenericTemplateFuncs()); err != nil { return nil, echo.NewHTTPError(http.StatusBadRequest, err.Error()) } m := models.TxMessage{ Subject: tpl.Subject, } // Render the message. if err := m.Render(dummySubscriber, &tpl, a.manager.GenericTemplateFuncs()); err != nil { return nil, echo.NewHTTPError(http.StatusBadRequest, err.Error()) } out = m.Body } return out, nil } ================================================ FILE: cmd/tx.go ================================================ package main import ( "encoding/json" "fmt" "io" "net/http" "net/textproto" "strings" "github.com/knadh/listmonk/internal/manager" "github.com/knadh/listmonk/models" "github.com/labstack/echo/v4" ) // SendTxMessage handles the sending of a transactional message. func (a *App) SendTxMessage(c echo.Context) error { var m models.TxMessage // If it's a multipart form, there may be file attachments. if strings.HasPrefix(c.Request().Header.Get("Content-Type"), "multipart/form-data") { form, err := c.MultipartForm() if err != nil { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", err.Error())) } data, ok := form.Value["data"] if !ok || len(data) != 1 { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "data")) } // Parse the JSON data. if err := json.Unmarshal([]byte(data[0]), &m); err != nil { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", fmt.Sprintf("data: %s", err.Error()))) } // Attach files. for _, f := range form.File["file"] { file, err := f.Open() if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.Ts("globals.messages.invalidFields", "name", fmt.Sprintf("file: %s", err.Error()))) } defer file.Close() b, err := io.ReadAll(file) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.Ts("globals.messages.invalidFields", "name", fmt.Sprintf("file: %s", err.Error()))) } m.Attachments = append(m.Attachments, models.Attachment{ Name: f.Filename, Header: manager.MakeAttachmentHeader(f.Filename, "base64", f.Header.Get("Content-Type")), Content: b, }) } } else if err := c.Bind(&m); err != nil { return err } // Validate fields. if r, err := a.validateTxMessage(m); err != nil { return err } else { m = r } // Get the cached tx template. tpl, err := a.manager.GetTpl(m.TemplateID) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.notFound", "name", fmt.Sprintf("template %d", m.TemplateID))) } var ( num = len(m.SubscriberEmails) isEmails = true ) if len(m.SubscriberIDs) > 0 { num = len(m.SubscriberIDs) isEmails = false } notFound := []string{} for n := range num { var sub models.Subscriber if m.SubscriberMode == models.TxSubModeExternal { // `external`: Always create an ephemeral "subscriber" and don't // lookup in the DB. sub = models.Subscriber{ Email: m.SubscriberEmails[n], } } else { // Default/fallback mode: lookup subscriber in DB. var ( subID int subEmail string ) if !isEmails { subID = m.SubscriberIDs[n] } else { subEmail = m.SubscriberEmails[n] } var err error sub, err = a.core.GetSubscriber(subID, "", subEmail) if err != nil { if er, ok := err.(*echo.HTTPError); ok && er.Code == http.StatusBadRequest { // `fallback`: Create an ephemeral "subscriber" if the subscriber wasn't found. if m.SubscriberMode == models.TxSubModeFallback { sub = models.Subscriber{ Email: subEmail, } } else { // `default`: log error and continue. notFound = append(notFound, fmt.Sprintf("%v", er.Message)) continue } } else { return err } } } // Render the message. if err := m.Render(sub, tpl, a.manager.GenericTemplateFuncs()); err != nil { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.errorFetching", "name")) } // Prepare the final message. msg := models.Message{} msg.Subscriber = sub msg.To = []string{sub.Email} msg.From = m.FromEmail msg.Subject = m.Subject msg.ContentType = m.ContentType msg.Messenger = m.Messenger msg.Body = m.Body msg.AltBody = []byte(m.AltBody) for _, a := range m.Attachments { msg.Attachments = append(msg.Attachments, models.Attachment{ Name: a.Name, Header: a.Header, Content: a.Content, }) } // Optional headers. if len(m.Headers) != 0 { msg.Headers = make(textproto.MIMEHeader, len(m.Headers)) for _, set := range m.Headers { for hdr, val := range set { msg.Headers.Add(hdr, val) } } } if err := a.manager.PushMessage(msg); err != nil { a.log.Printf("error sending message (%s): %v", msg.Subject, err) return err } } if len(notFound) > 0 { return echo.NewHTTPError(http.StatusBadRequest, strings.Join(notFound, "; ")) } return c.JSON(http.StatusOK, okResp{true}) } // validateTxMessage validates the tx message fields. func (a *App) validateTxMessage(m models.TxMessage) (models.TxMessage, error) { if len(m.SubscriberEmails) > 0 && m.SubscriberEmail != "" { return m, echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "do not send `subscriber_email`")) } if len(m.SubscriberIDs) > 0 && m.SubscriberID != 0 { return m, echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "do not send `subscriber_id`")) } if m.SubscriberEmail != "" { m.SubscriberEmails = append(m.SubscriberEmails, m.SubscriberEmail) } if m.SubscriberID != 0 { m.SubscriberIDs = append(m.SubscriberIDs, m.SubscriberID) } // Validate subscriber_mode. if m.SubscriberMode == "" { m.SubscriberMode = models.TxSubModeDefault } switch m.SubscriberMode { case models.TxSubModeDefault: // Need subscriber_emails OR subscriber_ids, but not both. if (len(m.SubscriberEmails) == 0 && len(m.SubscriberIDs) == 0) || (len(m.SubscriberEmails) > 0 && len(m.SubscriberIDs) > 0) { return m, echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "send subscriber_emails OR subscriber_ids")) } case models.TxSubModeFallback, models.TxSubModeExternal: // `fallback` and `external` can only use subscriber_emails. if len(m.SubscriberIDs) > 0 { return m, echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "subscriber_ids not allowed in fallback or external mode")) } if len(m.SubscriberEmails) == 0 { return m, echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "subscriber_emails")) } default: return m, echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "subscriber_mode")) } for n, email := range m.SubscriberEmails { if email != "" { em, err := a.importer.SanitizeEmail(email) if err != nil { return m, echo.NewHTTPError(http.StatusBadRequest, err.Error()) } m.SubscriberEmails[n] = em } } if m.FromEmail == "" { m.FromEmail = a.cfg.FromEmail } if m.Messenger == "" { m.Messenger = emailMsgr } else if !a.manager.HasMessenger(m.Messenger) { return m, echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("campaigns.fieldInvalidMessenger", "name", m.Messenger)) } return m, nil } ================================================ FILE: cmd/updates.go ================================================ package main import ( "encoding/json" "io" "net/http" "regexp" "time" "golang.org/x/mod/semver" ) const updateCheckURL = "https://update.listmonk.app/update.json" type AppUpdate struct { Update struct { ReleaseVersion string `json:"release_version"` ReleaseDate string `json:"release_date"` URL string `json:"url"` Description string `json:"description"` // This is computed and set locally based on the local version. IsNew bool `json:"is_new"` } `json:"update"` Messages []struct { Date string `json:"date"` Title string `json:"title"` Description string `json:"description"` URL string `json:"url"` Priority string `json:"priority"` } `json:"messages"` } var reSemver = regexp.MustCompile(`-(.*)`) // checkUpdates is a blocking function that checks for updates to the app // at the given intervals. On detecting a new update (new semver), it // sets the global update status that renders a prompt on the UI. func (a *App) checkUpdates(curVersion string, interval time.Duration) { // Strip -* suffix. curVersion = reSemver.ReplaceAllString(curVersion, "") fnCheck := func() { resp, err := http.Get(updateCheckURL) if err != nil { a.log.Printf("error checking for remote update: %v", err) return } if resp.StatusCode != 200 { a.log.Printf("non 200 response on remote update check: %d", resp.StatusCode) return } b, err := io.ReadAll(resp.Body) if err != nil { a.log.Printf("error reading remote update payload: %v", err) return } resp.Body.Close() var out AppUpdate if err := json.Unmarshal(b, &out); err != nil { a.log.Printf("error unmarshalling remote update payload: %v", err) return } // There is an update. Set it on the global app state. if semver.IsValid(out.Update.ReleaseVersion) { v := reSemver.ReplaceAllString(out.Update.ReleaseVersion, "") if semver.Compare(v, curVersion) > 0 { out.Update.IsNew = true a.log.Printf("new update %s found", out.Update.ReleaseVersion) } } a.Lock() a.update = &out a.Unlock() } // Give a 15 minute buffer after app start in case the admin wants to disable // update checks entirely and not make a request to upstream. time.Sleep(time.Minute * 15) fnCheck() // Thereafter, check every $interval. ticker := time.NewTicker(interval) defer ticker.Stop() for range ticker.C { fnCheck() } } ================================================ FILE: cmd/upgrade.go ================================================ package main import ( "fmt" "log" "strings" "github.com/jmoiron/sqlx" "github.com/knadh/koanf/v2" "github.com/knadh/listmonk/internal/migrations" "github.com/knadh/stuffbin" "github.com/lib/pq" "golang.org/x/mod/semver" ) // migFunc represents a migration function for a particular version. // fn (generally) executes database migrations and additionally // takes the filesystem and config objects in case there are additional bits // of logic to be performed before executing upgrades. fn is idempotent. type migFunc struct { version string fn func(*sqlx.DB, stuffbin.FileSystem, *koanf.Koanf, *log.Logger) error } // migList is the list of available migList ordered by the semver. // Each migration is a Go file in internal/migrations named after the semver. // The functions are named as: v0.7.0 => migrations.V0_7_0() and are idempotent. var migList = []migFunc{ {"v0.4.0", migrations.V0_4_0}, {"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}, {"v2.0.0", migrations.V2_0_0}, {"v2.1.0", migrations.V2_1_0}, {"v2.2.0", migrations.V2_2_0}, {"v2.3.0", migrations.V2_3_0}, {"v2.4.0", migrations.V2_4_0}, {"v2.5.0", migrations.V2_5_0}, {"v3.0.0", migrations.V3_0_0}, {"v4.0.0", migrations.V4_0_0}, {"v4.1.0", migrations.V4_1_0}, {"v5.0.0", migrations.V5_0_0}, {"v5.1.0", migrations.V5_1_0}, {"v6.0.0", migrations.V6_0_0}, {"v6.1.0", migrations.V6_1_0}, } // upgrade upgrades the database to the current version by running SQL migration files // for all version from the last known version to the current one. // If record is false, migration versions are not recorded in the DB (used for nightly builds). func upgrade(db *sqlx.DB, fs stuffbin.FileSystem, prompt bool, record bool) { if prompt { var ok string fmt.Printf("** IMPORTANT: Take a backup of the database before upgrading.\n") fmt.Print("continue (y/n)? ") if _, err := fmt.Scanf("%s", &ok); err != nil { lo.Fatalf("error reading value from terminal: %v", err) } if strings.ToLower(ok) != "y" { fmt.Println("upgrade cancelled") return } } _, toRun, err := getPendingMigrations(db) if err != nil { lo.Fatalf("error checking migrations: %v", err) } // No migrations to run. if len(toRun) == 0 { lo.Printf("no upgrades to run. Database is up to date.") return } // Execute migrations in succession. for _, m := range toRun { lo.Printf("running migration %s", m.version) if err := m.fn(db, fs, ko, lo); err != nil { lo.Fatalf("error running migration %s: %v", m.version, err) } // Record the migration version in the settings table. There was no // settings table until v0.7.0, so ignore the no-table errors. // For nightly builds, skip recording so migrations re-run on each boot. if record { if err := recordMigrationVersion(m.version, db); err != nil { if isTableNotExistErr(err) { continue } lo.Fatalf("error recording migration version %s: %v", m.version, err) } } } lo.Printf("upgrade complete") } // checkUpgrade checks if the current database schema matches the expected // binary version. func checkUpgrade(db *sqlx.DB) { lastVer, toRun, err := getPendingMigrations(db) if err != nil { lo.Fatalf("error checking migrations: %v", err) } // No migrations to run. if len(toRun) == 0 { return } var vers []string for _, m := range toRun { vers = append(vers, m.version) } lo.Fatalf(`there are %d pending database upgrade(s): %v. The last upgrade was %s. Backup the database and run listmonk --upgrade`, len(toRun), vers, lastVer) } // getPendingMigrations gets the pending migrations by comparing the last // recorded migration in the DB against all migrations listed in `migrations`. func getPendingMigrations(db *sqlx.DB) (string, []migFunc, error) { lastVer, err := getLastMigrationVersion(db) if err != nil { return "", nil, err } // Iterate through the migration versions and get everything above the last // upgraded semver. var toRun []migFunc for i, m := range migList { if semver.Compare(m.version, lastVer) > 0 { toRun = migList[i:] break } } return lastVer, toRun, nil } // getLastMigrationVersion returns the last migration semver recorded in the DB. // If there isn't any, `v0.0.0` is returned. func getLastMigrationVersion(db *sqlx.DB) (string, error) { var v string if err := db.Get(&v, ` SELECT COALESCE( (SELECT value->>-1 FROM settings WHERE key='migrations'), 'v0.0.0')`); err != nil { if isTableNotExistErr(err) { return "v0.0.0", nil } return v, err } return v, nil } // isTableNotExistErr checks if the given error represents a Postgres/pq // "table does not exist" error. func isTableNotExistErr(err error) bool { if p, ok := err.(*pq.Error); ok { // `settings` table does not exist. It was introduced in v0.7.0. if p.Code == "42P01" { return true } } return false } ================================================ FILE: cmd/users.go ================================================ package main import ( "net/http" "regexp" "strings" "github.com/knadh/listmonk/internal/auth" "github.com/knadh/listmonk/internal/core" "github.com/knadh/listmonk/internal/utils" "github.com/knadh/listmonk/models" "github.com/labstack/echo/v4" "github.com/pquerna/otp/totp" "gopkg.in/volatiletech/null.v6" ) var ( reUsername = regexp.MustCompile(`^[a-zA-Z0-9_\-\.@]+$`) ) // GetUser retrieves a single user by ID. func (a *App) GetUser(c echo.Context) error { // Get the user from the DB. id := getID(c) out, err := a.core.GetUser(id, "", "") if err != nil { return err } // Blank out the password hash in the response. out.Password = null.String{} return c.JSON(http.StatusOK, okResp{out}) } // GetUsers retrieves all users. func (a *App) GetUsers(c echo.Context) error { // Get all users from the DB. out, err := a.core.GetUsers() if err != nil { return err } // Blank out the password hash in the response. for n := range out { out[n].Password = null.String{} } return c.JSON(http.StatusOK, okResp{out}) } // CreateUser handles user creation. func (a *App) CreateUser(c echo.Context) error { var u auth.User if err := c.Bind(&u); err != nil { return err } u.Username = strings.TrimSpace(u.Username) u.Name = strings.TrimSpace(u.Name) email := strings.ToLower(strings.TrimSpace(u.Email.String)) // Validate fields. if !strHasLen(u.Username, 3, stdInputMaxLen) { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "username")) } if !reUsername.MatchString(u.Username) { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "username")) } if u.Type != auth.UserTypeAPI { if !utils.ValidateEmail(email) { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "email")) } if u.PasswordLogin { if !strHasLen(u.Password.String, 8, stdInputMaxLen) { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "password")) } } u.Email = null.String{String: email, Valid: true} } if u.Name == "" { u.Name = u.Username } // Create the user in the DB. user, err := a.core.CreateUser(u) if err != nil { return err } // Blank out the password hash in the response. if user.Type != auth.UserTypeAPI { user.Password = null.String{} } // Cache the API token for in-memory, off-DB /api/* request auth. if _, err := cacheUsers(a.core, a.auth); err != nil { return err } return c.JSON(http.StatusOK, okResp{user}) } // UpdateUser handles user modification. func (a *App) UpdateUser(c echo.Context) error { // Incoming params. var u auth.User if err := c.Bind(&u); err != nil { return err } u.Username = strings.TrimSpace(u.Username) u.Name = strings.TrimSpace(u.Name) email := strings.ToLower(strings.TrimSpace(u.Email.String)) // Validate fields. if !strHasLen(u.Username, 3, stdInputMaxLen) { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "username")) } if !reUsername.MatchString(u.Username) { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "username")) } // Get the user ID. id := getID(c) if u.Type != auth.UserTypeAPI { if !utils.ValidateEmail(email) { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "email")) } // Validate password if password login is enabled. if u.PasswordLogin && u.Password.String != "" { if !strHasLen(u.Password.String, 8, stdInputMaxLen) { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "password")) } if u.Password.String != "" { // If a password is sent, validate it before updating in the DB. If it's not set, leave the password in the DB untouched. if !strHasLen(u.Password.String, 8, stdInputMaxLen) { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "password")) } } else { // Get the user from the DB. user, err := a.core.GetUser(id, "", "") if err != nil { return err } // If password login is enabled, but there's no password in the DB and there's no incoming // password, throw an error. if !user.HasPassword { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "password")) } } } u.Email = null.String{String: email, Valid: true} } // Default the name to username if not set. if u.Name == "" { u.Name = u.Username } // Update the user in the DB. user, err := a.core.UpdateUser(id, u) if err != nil { return err } // Blank out the password hash in the response. user.Password = null.String{} // Cache the API token for in-memory, off-DB /api/* request auth. if _, err := cacheUsers(a.core, a.auth); err != nil { return err } return c.JSON(http.StatusOK, okResp{user}) } // DeleteUser handles the deletion of a single user by ID. func (a *App) DeleteUser(c echo.Context) error { // Delete the user(s) from the DB. id := getID(c) if err := a.core.DeleteUsers([]int{id}); err != nil { return err } // Cache the API token for in-memory, off-DB /api/* request auth. if _, err := cacheUsers(a.core, a.auth); err != nil { return err } return c.JSON(http.StatusOK, okResp{true}) } // DeleteUsers handles user deletion, either a single one (ID in the URI), or a list. func (a *App) DeleteUsers(c echo.Context) error { ids, err := getQueryInts("id", c.QueryParams()) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("globals.messages.invalidID")) } // Delete the user(s) from the DB. if err := a.core.DeleteUsers(ids); err != nil { return err } // Cache the API token for in-memory, off-DB /api/* request auth. if _, err := cacheUsers(a.core, a.auth); err != nil { return err } return c.JSON(http.StatusOK, okResp{true}) } // GetUserProfile fetches the uesr profile for the currently logged in user. func (a *App) GetUserProfile(c echo.Context) error { // Get the authenticated user. user := auth.GetUser(c) // Blank out the password hash in the response. user.Password.String = "" user.Password.Valid = false return c.JSON(http.StatusOK, okResp{user}) } // UpdateUserProfile update's the current user's profile. func (a *App) UpdateUserProfile(c echo.Context) error { // Get the authenticated user. user := auth.GetUser(c) // Incoming params. u := auth.User{} if err := c.Bind(&u); err != nil { return err } u.PasswordLogin = user.PasswordLogin u.Name = strings.TrimSpace(u.Name) email := strings.TrimSpace(u.Email.String) // Validate fields. if user.PasswordLogin { if !utils.ValidateEmail(email) { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "email")) } u.Email = null.String{String: email, Valid: true} } if u.PasswordLogin && u.Password.String != "" { if !strHasLen(u.Password.String, 8, stdInputMaxLen) { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "password")) } } // Update the user in the DB. out, err := a.core.UpdateUserProfile(user.ID, u) if err != nil { return err } // Blank out the password hash in the response. out.Password = null.String{} return c.JSON(http.StatusOK, okResp{out}) } // EnableTOTP enables TOTP 2FA for a user after verifying the code. func (a *App) EnableTOTP(c echo.Context) error { var ( u = c.Get(auth.UserHTTPCtxKey).(auth.User) secret = strings.TrimSpace(c.FormValue("secret")) code = strings.TrimSpace(c.FormValue("code")) ) if secret == "" || code == "" { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("globals.messages.invalidFields")) } // If password login is disabled, can't enable TOTP. if !u.PasswordLogin { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("public.invalidFeature")) } // If TOTP is already enabled, don't allow re-enabling. if u.TwofaType == models.TwofaTypeTOTP { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("users.twoFAAlreadyEnabled")) } // Verify the TOTP code. valid := totp.Validate(code, secret) if !valid { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("users.invalidTOTPCode")) } // Enable TOTP in the DB. if err := a.core.SetTwoFA(u.ID, models.TwofaTypeTOTP, secret); err != nil { return err } return c.JSON(http.StatusOK, okResp{true}) } // DisableTOTP disables TOTP 2FA for a user after verifying the password. func (a *App) DisableTOTP(c echo.Context) error { var ( u = c.Get(auth.UserHTTPCtxKey).(auth.User) password = c.FormValue("password") ) // TOTP isn't enabled. if u.TwofaType != models.TwofaTypeTOTP { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("users.twoFANotEnabled")) } // Validate password. if !strHasLen(password, 8, stdInputMaxLen) { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "password")) } // Verify the password. if _, err := a.core.LoginUser(u.Username, password); err != nil { return echo.NewHTTPError(http.StatusForbidden, a.i18n.T("users.invalidPassword")) } // Disable TOTP in the DB. if err := a.core.SetTwoFA(u.ID, models.TwofaTypeNone, ""); err != nil { return err } return c.JSON(http.StatusOK, okResp{true}) } // cacheUsers fetches (API) users and caches them in the auth module. // It also returns a bool indicating whether there are any actual users in the DB at all, // which if there aren't, the first time user setup needs to be run. func cacheUsers(co *core.Core, a *auth.Auth) (bool, error) { users, err := co.GetUsers() if err != nil { return false, err } hasUser := false apiUsers := make([]auth.User, 0, len(users)) for _, u := range users { if u.Type == auth.UserTypeAPI && u.Status == auth.UserStatusEnabled { apiUsers = append(apiUsers, u) } if u.Type == auth.UserTypeUser { hasUser = true } } a.CacheAPIUsers(apiUsers) return hasUser, nil } ================================================ FILE: cmd/utils.go ================================================ package main import ( "crypto/rand" "fmt" "net/url" "path/filepath" "regexp" "slices" "strconv" "strings" ) var ( regexpSpaces = regexp.MustCompile(`[\s]+`) ) // inArray checks if a string is present in a list of strings. func inArray(val string, vals []string) (ok bool) { return slices.Contains(vals, val) } // makeFilename sanitizes a filename (user supplied upload filenames). func makeFilename(fName string) string { name := strings.TrimSpace(fName) if name == "" { name, _ = generateRandomString(10) } // replace whitespace with "-" name = regexpSpaces.ReplaceAllString(name, "-") return filepath.Base(name) } // appendSuffixToFilename adds a string suffix to the filename while keeping the file extension. func appendSuffixToFilename(filename, suffix string) string { ext := filepath.Ext(filename) name := strings.TrimSuffix(filename, ext) return fmt.Sprintf("%s_%s%s", name, suffix, ext) } // makeMsgTpl takes a page title, heading, and message and returns // a msgTpl that can be rendered as an HTML view. This is used for // rendering arbitrary HTML views with error and success messages. func makeMsgTpl(pageTitle, heading, msg string) msgTpl { if heading == "" { heading = pageTitle } err := msgTpl{} err.Title = pageTitle err.MessageTitle = heading err.Message = msg return err } // parseStringIDs takes a slice of numeric string IDs and // parses each number into an int64 and returns a slice of the // resultant values. func parseStringIDs(s []string) ([]int, error) { vals := make([]int, 0, len(s)) for _, v := range s { i, err := strconv.Atoi(v) if err != nil { return nil, err } if i < 1 { return nil, fmt.Errorf("%d is not a valid ID", i) } vals = append(vals, i) } return vals, nil } // generateRandomString generates a cryptographically random, alphanumeric string of length n. func generateRandomString(n int) (string, error) { const dictionary = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" var bytes = make([]byte, n) if _, err := rand.Read(bytes); err != nil { return "", err } for k, v := range bytes { bytes[k] = dictionary[v%byte(len(dictionary))] } return string(bytes), nil } // strHasLen checks if the given string has a length within min-max. func strHasLen(str string, min, max int) bool { return len(str) >= min && len(str) <= max } // getQueryInts parses the list of given query param values into ints. func getQueryInts(param string, qp url.Values) ([]int, error) { var out []int if vals, ok := qp[param]; ok { for _, v := range vals { if v == "" { continue } listID, err := strconv.Atoi(v) if err != nil { return nil, err } out = append(out, listID) } } return out, nil } ================================================ FILE: config.toml.sample ================================================ [app] # Interface and port where the app will run its webserver. The default value # of localhost will only listen to connections from the current machine. To # listen on all interfaces use '0.0.0.0'. To listen on the default web address # port, use port 80 (this will require running with elevated permissions). address = "localhost:9000" # Database. [db] host = "localhost" port = 5432 user = "listmonk" password = "listmonk" # Ensure that this database has been created in Postgres. database = "listmonk" ssl_mode = "disable" max_open = 25 max_idle = 25 max_lifetime = "300s" # Optional space separated Postgres DSN params. eg: "application_name=listmonk gssencmode=disable" params = "" ================================================ FILE: dev/.gitignore ================================================ !config.toml ================================================ FILE: dev/README.md ================================================ # Docker suite for development **NOTE**: This exists only for local development. If you're interested in using Docker for a production setup, visit the [docs](https://listmonk.app/docs/installation/#docker) instead. ### Objective The purpose of this Docker suite for local development is to isolate all the dev dependencies in a Docker environment. The containers have a host volume mounted inside for the entire app directory. This helps us to not do a full `docker build` for every single local change, only restarting the Docker environment is enough. ## Setting up a dev suite To spin up a local suite of: - PostgreSQL - Mailhog - Node.js frontend app - Golang backend app ### Verify your config file The config file provided at `dev/config.toml` will be used when running the containerized development stack. Make sure the values set within are suitable for the feature you're trying to develop. ### Setup DB Running this will build the appropriate images and initialize the database. ```bash make init-dev-docker ``` ### Start frontend and backend apps Running this start your local development stack. ```bash make dev-docker ``` Visit `http://localhost:8080` on your browser. ### Tear down This will tear down all the data, including DB. ```bash make rm-dev-docker ``` ### See local changes in action - Backend: Anytime you do a change to the Go app, it needs to be compiled. Just run `make dev-docker` again and that should automatically handle it for you. - Frontend: Anytime you change the frontend code, you don't need to do anything. Since `yarn` is watching for all the changes and we have mounted the code inside the docker container, `yarn` server automatically restarts. ================================================ FILE: dev/app.Dockerfile ================================================ FROM golang:1.24.1 AS go FROM node:16 AS node COPY --from=go /usr/local/go /usr/local/go ENV GOPATH /go ENV CGO_ENABLED=0 ENV PATH $GOPATH/bin:/usr/local/go/bin:$PATH WORKDIR /app CMD [ "sleep infinity" ] ================================================ FILE: dev/config.toml ================================================ [app] # Interface and port where the app will run its webserver. The default value # of localhost will only listen to connections from the current machine. To # listen on all interfaces use '0.0.0.0'. To listen on the default web address # port, use port 80 (this will require running with elevated permissions). address = "0.0.0.0:9000" # Database. [db] host = "db" port = 5432 user = "listmonk-dev" password = "listmonk-dev" # Ensure that this database has been created in Postgres. database = "listmonk-dev" ssl_mode = "disable" max_open = 25 max_idle = 25 max_lifetime = "300s" # Optional space separated Postgres DSN params. eg: "application_name=listmonk gssencmode=disable" params = "" ================================================ FILE: dev/docker-compose.yml ================================================ version: "3" services: adminer: image: adminer:4.8.1-standalone restart: always ports: - 8070:8080 networks: - listmonk-dev mailhog: image: mailhog/mailhog:v1.0.1 ports: - "1025:1025" # SMTP - "8025:8025" # UI networks: - listmonk-dev db: image: postgres:13 ports: - "5432:5432" networks: - listmonk-dev environment: - POSTGRES_PASSWORD=listmonk-dev - POSTGRES_USER=listmonk-dev - POSTGRES_DB=listmonk-dev restart: unless-stopped volumes: - type: volume source: listmonk-dev-db target: /var/lib/postgresql/data front: build: context: ../ dockerfile: dev/app.Dockerfile command: ["make", "run-frontend"] ports: - "8080:8080" environment: - LISTMONK_API_URL=http://backend:9000 depends_on: - db volumes: - ../:/app networks: - listmonk-dev backend: build: context: ../ dockerfile: dev/app.Dockerfile command: ["make", "run-backend-docker"] ports: - "9000:9000" depends_on: - db volumes: - ../:/app - $GOPATH/pkg/mod/cache:/go/pkg/mod/cache networks: - listmonk-dev volumes: listmonk-dev-db: networks: listmonk-dev: ================================================ FILE: docker-compose.yml ================================================ # All LISTMONK_* env variables also support the LISTMONK_*_FILE pattern for loading secrets from files with Docker secrets and Podman # eg: LISTMONK_ADMIN_USER -> LISTMONK_ADMIN_USER_FILE=/path/to/file_with_value x-db-credentials: &db-credentials # Use the default POSTGRES_ credentials if they're available or simply default to "listmonk" POSTGRES_USER: &db-user listmonk # for database user, password, and database name POSTGRES_PASSWORD: &db-password listmonk POSTGRES_DB: &db-name listmonk services: # listmonk app app: image: listmonk/listmonk:latest container_name: listmonk_app restart: unless-stopped ports: - "9000:9000" # To change the externally exposed port, change to: $custom_port:9000 networks: - listmonk hostname: listmonk.example.com # Recommend using FQDN for hostname depends_on: - db command: [sh, -c, "./listmonk --install --idempotent --yes --config '' && ./listmonk --upgrade --yes --config '' && ./listmonk --config ''"] # --config (file) param is set to empty so that listmonk only uses the env vars (below) for config. # --install --idempotent ensures that DB installation happens only once on an empty DB, on the first ever start. # --upgrade automatically runs any DB migrations when a new image is pulled. environment: # The same params as in config.toml are passed as env vars here. LISTMONK_app__address: 0.0.0.0:9000 LISTMONK_db__user: *db-user LISTMONK_db__password: *db-password LISTMONK_db__database: *db-name LISTMONK_db__host: db LISTMONK_db__port: 5432 LISTMONK_db__ssl_mode: disable LISTMONK_db__max_open: 25 LISTMONK_db__max_idle: 25 LISTMONK_db__max_lifetime: 300s TZ: Etc/UTC LISTMONK_ADMIN_USER: ${LISTMONK_ADMIN_USER:-} # If these (optional) are set during the first `docker compose up`, then the Super Admin user is automatically created. LISTMONK_ADMIN_PASSWORD: ${LISTMONK_ADMIN_PASSWORD:-} # Otherwise, the user can be setup on the web app after the first visit to http://localhost:9000 volumes: - ./uploads:/listmonk/uploads:rw # Mount an uploads directory on the host to /listmonk/uploads inside the container. # To use this, change directory path in Admin -> Settings -> Media to /listmonk/uploads # Postgres database db: image: postgres:17-alpine container_name: listmonk_db restart: unless-stopped ports: - "127.0.0.1:5432:5432" # Only bind on the local interface. To connect to Postgres externally, change this to 0.0.0.0 networks: - listmonk environment: <<: *db-credentials healthcheck: test: ["CMD-SHELL", "pg_isready -U listmonk"] interval: 10s timeout: 5s retries: 6 volumes: - type: volume source: listmonk-data target: /var/lib/postgresql/data networks: listmonk: volumes: listmonk-data: ================================================ FILE: docker-entrypoint.sh ================================================ #!/bin/sh set -e export PUID=${PUID:-0} export PGID=${PGID:-0} export GROUP_NAME="app" export USER_NAME="app" # This function evaluates if the supplied PGID is already in use # if it is not in use, it creates the group with the PGID # if it is in use, it sets the GROUP_NAME to the existing group create_group() { if ! getent group ${PGID} > /dev/null 2>&1; then addgroup -g ${PGID} ${GROUP_NAME} else existing_group=$(getent group ${PGID} | cut -d: -f1) export GROUP_NAME=${existing_group} fi } # This function evaluates if the supplied PUID is already in use # if it is not in use, it creates the user with the PUID and PGID create_user() { if ! getent passwd ${PUID} > /dev/null 2>&1; then adduser -u ${PUID} -G ${GROUP_NAME} -s /bin/sh -D ${USER_NAME} else existing_user=$(getent passwd ${PUID} | cut -d: -f1) export USER_NAME=${existing_user} fi } # Run the needed functions to create the user and group create_group create_user load_secret_files() { # Save and restore IFS old_ifs="$IFS" IFS=' ' # Capture all env variables starting with LISTMONK_ and ending with _FILE. # It's value is assumed to be a file path with its actual value. for line in $(env | grep '^LISTMONK_.*_FILE='); do var="${line%%=*}" fpath="${line#*=}" # If it's a valid file, read its contents and assign it to the var # without the _FILE suffix. # Eg: LISTMONK_DB_USER_FILE=/run/secrets/user -> LISTMONK_DB_USER=$(contents of /run/secrets/user) if [ -f "$fpath" ]; then new_var="${var%_FILE}" export "$new_var"="$(cat "$fpath")" fi done IFS="$old_ifs" } # Load env variables from files if LISTMONK_*_FILE variables are set. load_secret_files # Try to set the ownership of the app directory to the app user. if ! chown -R ${PUID}:${PGID} /listmonk 2>/dev/null; then echo "Warning: Failed to change ownership of /listmonk. Readonly volume?" fi echo "Launching listmonk with user=[${USER_NAME}] group=[${GROUP_NAME}] PUID=[${PUID}] PGID=[${PGID}]" # If running as root and PUID is not 0, then execute command as PUID # this allows us to run the container as a non-root user if [ "$(id -u)" = "0" ] && [ "${PUID}" != "0" ]; then su-exec ${PUID}:${PGID} "$@" else exec "$@" fi ================================================ FILE: docs/README.md ================================================ # Static website and docs This repository contains the source for the static website https://listmonk.app - The website is in `site` and is built with hugo (run `hugo serve` inside `site` to preview). - Documentation is in `docs` and is built with mkdocs (inside `docs`, run `mkdocs serve` to preview after running `pip install -r requirements.txt`) - `i18n` directory has the static UI for i18n translations: https://listmonk.app/i18n ================================================ FILE: docs/docs/content/apis/apis.md ================================================ # APIs All features that are available on the listmonk dashboard are also available as REST-like HTTP APIs that can be interacted with directly. Request and response bodies are JSON. This allows easy scripting of listmonk and integration with other systems, for instance, synchronisation with external subscriber databases. !!! note If you come across API calls that are yet to be documented, please consider contributing to docs. ## Auth HTTP API requests support BasicAuth and a Authorization `token` headers. API users and tokens with the required permissions can be created and managed on the admin UI (Admin -> Users). ##### BasicAuth example ```shell curl -u "api_user:token" http://localhost:9000/api/lists ``` ##### Authorization token example ```shell curl -H "Authorization: token api_user:token" http://localhost:9000/api/lists ``` ## Permissions **User role**: Permissions allowed for a user are defined as a *User role* (Admin -> User roles) and then attached to a user. **List role**: Read / write permissions per-list can be defined as a *List role* (Admin -> User roles) and then attached to a user. In a *User role*, `lists:get_all` or `lists:manage_all` permission supercede and override any list specific permissions for a user defined in a *List role*. To manage lists and subscriber list subscriptions via API requests, ensure that the appropriate permissions are attached to the API user. ______________________________________________________________________ ## Response structure ### Successful request ```http HTTP/1.1 200 OK Content-Type: application/json { "data": {} } ``` All responses from the API server are JSON with the content-type application/json unless explicitly stated otherwise. A successful 200 OK response always has a JSON response body with a status key with the value success. The data key contains the full response payload. ### Failed request ```http HTTP/1.1 500 Server error Content-Type: application/json { "message": "Error message" } ``` A failure response is preceded by the corresponding 40x or 50x HTTP header. There may be an optional `data` key with additional payload. ### Timestamps All timestamp fields are in the format `2019-01-01T09:00:00.000000+05:30`. The seconds component is suffixed by the milliseconds, followed by the `+` and the timezone offset. ### Common HTTP error codes | Code | | | ----- | ----------------------------------------------------------------------------| | 400 | Missing or bad request parameters or values | | 403 | Session expired or invalidate. Must relogin | | 404 | Request resource was not found | | 405 | Request method (GET, POST etc.) is not allowed on the requested endpoint | | 410 | The requested resource is gone permanently | | 422 | Unprocessable entity. Unable to process request as it contains invalid data | | 429 | Too many requests to the API (rate limiting) | | 500 | Something unexpected went wrong | | 502 | The backend OMS is down and the API is unable to communicate with it | | 503 | Service unavailable; the API is down | | 504 | Gateway timeout; the API is unreachable | ## OpenAPI (Swagger) spec The auto-generated OpenAPI (Swagger) specification site for the APIs are available at [**listmonk.app/docs/swagger**](https://listmonk.app/docs/swagger/) ================================================ FILE: docs/docs/content/apis/bounces.md ================================================ # API / Bounces Method | Endpoint | Description ---------|---------------------------------------------------------|------------------------------------------------ GET | [/api/bounces](#get-apibounces) | Retrieve bounce records. DELETE | [/api/bounces](#delete-apibounces) | Delete all/multiple bounce records. DELETE | [/api/bounces/{bounce_id}](#delete-apibouncesbounce_id) | Delete specific bounce record. ______________________________________________________________________ #### GET /api/bounces Retrieve the bounce records. ##### Parameters | Name | Type | Required | Description | |:-----------|:---------|:---------|:-----------------------------------------------------------------| | campaign_id| number | | Bounce record retrieval for particular campaign id | | page | number | | Page number for pagination. | | per_page | number | | Results per page. Set to 'all' to return all results. | | source | string | | | | order_by | string | | Fields by which bounce records are ordered. Options:"email", "campaign_name", "source", "created_at". | | order | number | | Sorts the result. Allowed values: 'asc','desc' | ##### Example Request ```shell curl -u "api_user:token" -X GET 'http://localhost:9000/api/bounces?campaign_id=1&page=1&per_page=2' \ -H 'accept: application/json' -H 'Content-Type: application/x-www-form-urlencoded' \ --data '{"source":"demo","order_by":"created_at","order":"asc"}' ``` ##### Example Response ```json { "data": { "results": [ { "id": 839971, "type": "hard", "source": "demo", "meta": { "some": "parameter" }, "created_at": "2024-08-20T23:54:22.851858Z", "email": "gilles.deleuze@example.app", "subscriber_uuid": "32ca1f3e-1a1d-42e1-af04-df0757f420f3", "subscriber_id": 60, "campaign": { "id": 1, "name": "Test campaign" } }, { "id": 839725, "type": "hard", "source": "demo", "meta": { "some": "parameter" }, "created_at": "2024-08-20T22:46:36.393547Z", "email": "gottfried.leibniz@example.app", "subscriber_uuid": "5911d3f4-2346-4bfc-aad2-eb319ab0e879", "subscriber_id": 13, "campaign": { "id": 1, "name": "Test campaign" } } ], "query": "", "total": 528, "per_page": 2, "page": 1 } } ``` ______________________________________________________________________ #### DELETE /api/bounces To delete all bounces. ##### Parameters | Name | Type | Required | Description | |:--------|:----------|:---------|:-------------------------------------| | all | bool | Yes | Bool to confirm deleting all bounces | ##### Example Request ```shell curl -u 'api_username:access_token' -X DELETE 'http://localhost:9000/api/bounces?all=true' ``` ##### Example Response ```json { "data": true } ``` ______________________________________________________________________ #### DELETE /api/bounces To delete multiple bounce records. ##### Parameters | Name | Type | Required | Description | |:--------|:----------|:---------|:-------------------------------------| | id | number | Yes | Id's of bounce records to delete. | ##### Example Request ```shell curl -u 'api_username:access_token' -X DELETE 'http://localhost:9000/api/bounces?id=840965&id=840168&id=840879' ``` ##### Example Response ```json { "data": true } ``` ______________________________________________________________________ #### DELETE /api/bounces/{bounce_id} To delete specific bounce id. ##### Example Request ```shell curl -u 'api_username:access_token' -X DELETE 'http://localhost:9000/api/bounces/840965' ``` ##### Example Response ```json { "data": true } ``` ================================================ FILE: docs/docs/content/apis/campaigns.md ================================================ # API / Campaigns | Method | Endpoint | Description | | :----- | :-------------------------------------------------------------------------- | :---------------------------------------- | | GET | [/api/campaigns](#get-apicampaigns) | Retrieve all campaigns. | | GET | [/api/campaigns/{campaign_id}](#get-apicampaignscampaign_id) | Retrieve a specific campaign. | | GET | [/api/campaigns/{campaign_id}/preview](#get-apicampaignscampaign_idpreview) | Retrieve preview of a campaign. | | GET | [/api/campaigns/running/stats](#get-apicampaignsrunningstats) | Retrieve stats of specified campaigns. | | GET | [/api/campaigns/analytics/{type}](#get-apicampaignsanalyticstype) | Retrieve view counts for a campaign. | | POST | [/api/campaigns](#post-apicampaigns) | Create a new campaign. | | POST | [/api/campaigns/{campaign_id}/test](#post-apicampaignscampaign_idtest) | Test campaign with arbitrary subscribers. | | PUT | [/api/campaigns/{campaign_id}](#put-apicampaignscampaign_id) | Update a campaign. | | PUT | [/api/campaigns/{campaign_id}/status](#put-apicampaignscampaign_idstatus) | Change status of a campaign. | | PUT | [/api/campaigns/{campaign_id}/archive](#put-apicampaignscampaign_idarchive) | Publish campaign to public archive. | | DELETE | [/api/campaigns/{campaign_id}](#delete-apicampaignscampaign_id) | Delete a campaign. | | DELETE | [/api/campaigns](#delete-apicampaigns) | Delete multiple campaigns. | ____________________________________________________________________________________________________________________________________ #### GET /api/campaigns Retrieve all campaigns. ##### Example Request ```shell curl -u "api_user:token" -X GET 'http://localhost:9000/api/campaigns?page=1&per_page=100' ``` ##### Parameters | Name | Type | Required | Description | | :------- | :------- | :------- | :----------------------------------------------------------------------- | | order | string | | Sorting order: ASC for ascending, DESC for descending. | | order_by | string | | Result sorting field. Options: name, status, created_at, updated_at. | | query | string | | String to filtter by campaign name and subject (fulltext and substring). | | status | []string | | Status to filter campaigns. Repeat in the query for multiple values. | | tags | []string | | Tags to filter campaigns. Repeat in the query for multiple values. | | page | number | | Page number for paginated results. | | per_page | number | | Results per page. Set as 'all' for all results. | | no_body | boolean | | When set to true, returns response without body content. | ##### Example Response ```json { "data": { "results": [ { "id": 1, "created_at": "2020-03-14T17:36:41.29451+01:00", "updated_at": "2020-03-14T17:36:41.29451+01:00", "views": 0, "clicks": 0, "lists": [ { "id": 1, "name": "Default list" } ], "started_at": null, "to_send": 0, "sent": 0, "uuid": "57702beb-6fae-4355-a324-c2fd5b59a549", "type": "regular", "name": "Test campaign", "subject": "Welcome to listmonk", "from_email": "No ReplyHi 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`, `campaign_visual`, or `tx`) | | subject | string | | Subject line for the template (only for `tx`) | | body_source | string | | If type is `campaign_visual`, the JSON source for the email-builder tempalate | | 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\" . }}", "body_source": null, "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\" . }}", "body_source": null, "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 } ``` ================================================ FILE: docs/docs/content/apis/transactional.md ================================================ # 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`. | | subscriber_mode | string | | Subscriber lookup mode: `default`, `fallback`, or `external` | | template_id | number | Yes | ID of the transactional template to be used for the message. | | from_email | string | | Optional sender email. | | subject | string | | Optional subject. If empty, the subject defined on the template is used | | 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`. | | altbody | string | | Optional alternate plaintext body for multipart HTML emails. | ##### Subscriber modes The `subscriber_mode` parameter controls how the recipients (subscribers or non-subscriber recipients) are resolved. | Mode | Description | | :--------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `default` | Recipients must exist as subscribers in the database. Pass either `subscriber_emails` or `subscriber_ids`. | | `fallback` | Only accepts `subscriber_emails` and looks up subscribers in the database. If not found, sends the message to the e-mail anyway. In the template, apart from `{{ .Subscriber.Email }}`, other subscriber fields such as `.Name`. will be empty. Use `{{ Tx.Data.* }}` instead. | | `external` | Sends to the given `subscriber_emails` without subscriber lookup in the database. In the template, apart from `{{ .Subscriber.Email }}`, other subscriber fields such as `.Name`. will be empty. Use `{{ Tx.Data.* }}` instead. | ##### 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 } ``` ##### Example with external mode Send to arbitrary email addresses without requiring them to be subscribers: ```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_mode": "external", "subscriber_emails": ["recipient@example.com"], "template_id": 2, "data": {"name": "John", "order_id": "1234"}, "content_type": "html" } EOF ``` In the template, use `{{ .Tx.Data.name }}`, `{{ .Tx.Data.order_id }}`, etc. to access the data. ______________________________________________________________________ #### 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"' ``` ================================================ FILE: docs/docs/content/archives.md ================================================ # 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": {} } ```  ================================================ FILE: docs/docs/content/bounces.md ================================================ # 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. ### Bounce classification listmonk applies a series of heuristics looking for keywords in the bounced mail body to guess if it is a 'soft' bounce or a 'hard' bounce. For instance, 4.x.x and 5.x.x error status codes, common strings such as "mailbox not found" etc. If none of the heuristics match, then the bounce mail is considered to be 'soft' by default. ## 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 "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; ``` ================================================ FILE: docs/docs/content/concepts.md ================================================ # 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 opt-in 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 opt-in or double opt-in. Subscribers added to double opt-in 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). ================================================ FILE: docs/docs/content/configuration.md ================================================ # 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. ================================================ FILE: docs/docs/content/developer-setup.md ================================================ # 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` ================================================ FILE: docs/docs/content/external-integration.md ================================================ # 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. ================================================ FILE: docs/docs/content/i18n.md ================================================ # 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