diff --git a/.goreleaser.yml b/.goreleaser.yml index 4665e58..669d5df 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -23,7 +23,7 @@ builds: hooks: # stuff executables with static assets. - post: make pack-bin bin={{ .Path }} + post: make pack-bin BIN={{ .Path }} archives: - format: tar.gz diff --git a/Makefile b/Makefile index 9ef68aa..f8be343 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,8 @@ STATIC := config.toml.sample \ static/public:/public \ static/email-templates \ frontend/dist/favicon.png:/frontend/favicon.png \ - frontend/dist/frontend:/frontend + frontend/dist/frontend:/frontend \ + i18n:/i18n # Install dependencies for building. .PHONY: deps @@ -51,7 +52,7 @@ dist: build build-frontend # in the .goreleaser post-build hook. .PHONY: pack-bin pack-bin: - stuffbin -a stuff -in $(bin) -out $(bin) ${STATIC} + stuffbin -a stuff -in ${BIN} -out ${BIN} ${STATIC} # Use goreleaser to do a dry run producing local builds. .PHONY: release-dry diff --git a/cmd/admin.go b/cmd/admin.go index e2e80af..289ef20 100644 --- a/cmd/admin.go +++ b/cmd/admin.go @@ -14,12 +14,15 @@ import ( ) type configScript struct { - RootURL string `json:"rootURL"` - FromEmail string `json:"fromEmail"` - Messengers []string `json:"messengers"` - MediaProvider string `json:"mediaProvider"` - NeedsRestart bool `json:"needsRestart"` - Update *AppUpdate `json:"update"` + RootURL string `json:"rootURL"` + FromEmail string `json:"fromEmail"` + Messengers []string `json:"messengers"` + MediaProvider string `json:"mediaProvider"` + NeedsRestart bool `json:"needsRestart"` + Update *AppUpdate `json:"update"` + Langs []i18nLang `json:"langs"` + EnablePublicSubPage bool `json:"enablePublicSubscriptionPage"` + Lang json.RawMessage `json:"lang"` } // handleGetConfigScript returns general configuration as a Javascript @@ -28,12 +31,24 @@ func handleGetConfigScript(c echo.Context) error { var ( app = c.Get("app").(*App) out = configScript{ - RootURL: app.constants.RootURL, - FromEmail: app.constants.FromEmail, - MediaProvider: app.constants.MediaProvider, + RootURL: app.constants.RootURL, + FromEmail: app.constants.FromEmail, + MediaProvider: app.constants.MediaProvider, + EnablePublicSubPage: app.constants.EnablePublicSubPage, } ) + // Language list. + langList, err := geti18nLangList(app.constants.Lang, app) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, + fmt.Sprintf("Error loading language list: %v", err)) + } + out.Langs = langList + + // Current language. + out.Lang = json.RawMessage(app.i18n.JSON()) + // Sort messenger names with `email` always as the first item. var names []string for name := range app.messengers { @@ -51,13 +66,17 @@ func handleGetConfigScript(c echo.Context) error { out.Update = app.update app.Unlock() - var ( - b = bytes.Buffer{} - j = json.NewEncoder(&b) - ) + // Write the Javascript variable opening; + b := bytes.Buffer{} b.Write([]byte(`var CONFIG = `)) - _ = j.Encode(out) - return c.Blob(http.StatusOK, "application/javascript", b.Bytes()) + + // Encode the config payload as JSON and write as the variable's value assignment. + j := json.NewEncoder(&b) + if err := j.Encode(out); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts("admin.errorMarshallingConfig", "error", err.Error())) + } + return c.Blob(http.StatusOK, "application/javascript; charset=utf-8", b.Bytes()) } // handleGetDashboardCharts returns chart data points to render ont he dashboard. @@ -69,7 +88,7 @@ func handleGetDashboardCharts(c echo.Context) error { if err := app.queries.GetDashboardCharts.Get(&out); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error fetching dashboard stats: %s", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorFetching", "name", "dashboard charts", "error", pqErrMsg(err))) } return c.JSON(http.StatusOK, okResp{out}) @@ -84,7 +103,7 @@ func handleGetDashboardCounts(c echo.Context) error { if err := app.queries.GetDashboardCounts.Get(&out); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error fetching dashboard statsc counts: %s", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorFetching", "name", "dashboard stats", "error", pqErrMsg(err))) } return c.JSON(http.StatusOK, okResp{out}) diff --git a/cmd/campaigns.go b/cmd/campaigns.go index b3b66be..65cc99b 100644 --- a/cmd/campaigns.go +++ b/cmd/campaigns.go @@ -14,6 +14,7 @@ import ( "time" "github.com/gofrs/uuid" + "github.com/jaytaylor/html2text" "github.com/knadh/listmonk/internal/messenger" "github.com/knadh/listmonk/internal/subimporter" "github.com/knadh/listmonk/models" @@ -106,10 +107,12 @@ func handleGetCampaigns(c echo.Context) error { if err := db.Select(&out.Results, stmt, id, pq.StringArray(status), query, pg.Offset, pg.Limit); err != nil { app.log.Printf("error fetching campaigns: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error fetching campaigns: %s", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorFetching", + "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } if single && len(out.Results) == 0 { - return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.") + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("campaigns.notFound", "name", "{globals.terms.campaign}")) } if len(out.Results) == 0 { out.Results = []models.Campaign{} @@ -131,7 +134,8 @@ func handleGetCampaigns(c echo.Context) error { if err := out.Results.LoadStats(app.queries.GetCampaignStats); err != nil { app.log.Printf("error fetching campaign stats: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error fetching campaign stats: %v", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorFetching", + "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } if single { @@ -146,7 +150,7 @@ func handleGetCampaigns(c echo.Context) error { return c.JSON(http.StatusOK, okResp{out}) } -// handlePreviewTemplate renders the HTML preview of a campaign body. +// handlePreviewCampaign renders the HTML preview of a campaign body. func handlePreviewCampaign(c echo.Context) error { var ( app = c.Get("app").(*App) @@ -157,18 +161,20 @@ func handlePreviewCampaign(c echo.Context) error { ) if id < 1 { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } err := app.queries.GetCampaignForPreview.Get(camp, id) if err != nil { if err == sql.ErrNoRows { - return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.") + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}")) } app.log.Printf("error fetching campaign: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error fetching campaign: %s", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorFetching", + "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } var sub models.Subscriber @@ -180,7 +186,8 @@ func handlePreviewCampaign(c echo.Context) error { } else { app.log.Printf("error fetching subscriber: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error fetching subscriber: %s", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorFetching", + "name", "{globals.terms.subscriber}", "error", pqErrMsg(err))) } } @@ -192,7 +199,7 @@ func handlePreviewCampaign(c echo.Context) error { if err := camp.CompileTemplate(app.manager.TemplateFuncs(camp)); err != nil { app.log.Printf("error compiling template: %v", err) return echo.NewHTTPError(http.StatusBadRequest, - fmt.Sprintf("Error compiling template: %v", err)) + app.i18n.Ts("templates.errorCompiling", "error", err.Error())) } // Render the message body. @@ -200,12 +207,23 @@ func handlePreviewCampaign(c echo.Context) error { if err := m.Render(); err != nil { app.log.Printf("error rendering message: %v", err) return echo.NewHTTPError(http.StatusBadRequest, - fmt.Sprintf("Error rendering message: %v", err)) + app.i18n.Ts("templates.errorRendering", "error", err.Error())) } return c.HTML(http.StatusOK, string(m.Body())) } +// handleCampainBodyToText converts an HTML campaign body to plaintext. +func handleCampainBodyToText(c echo.Context) error { + out, err := html2text.FromString(c.FormValue("body"), + html2text.Options{PrettyTables: false}) + if err != nil { + return err + } + + return c.HTML(http.StatusOK, string(out)) +} + // handleCreateCampaign handles campaign creation. // Newly created campaigns are always drafts. func handleCreateCampaign(c echo.Context) error { @@ -237,7 +255,8 @@ func handleCreateCampaign(c echo.Context) error { uu, err := uuid.NewV4() if err != nil { app.log.Printf("error generating UUID: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, "Error generating UUID") + return echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts("globals.messages.errorUUID", "error", err.Error())) } // Insert and read ID. @@ -249,6 +268,7 @@ func handleCreateCampaign(c echo.Context) error { o.Subject, o.FromEmail, o.Body, + o.AltBody, o.ContentType, o.SendAt, pq.StringArray(normalizeTags(o.Tags)), @@ -257,13 +277,13 @@ func handleCreateCampaign(c echo.Context) error { o.ListIDs, ); err != nil { if err == sql.ErrNoRows { - return echo.NewHTTPError(http.StatusBadRequest, - "There aren't any subscribers in the target lists to create the campaign.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.noSubs")) } app.log.Printf("error creating campaign: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error creating campaign: %v", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorCreating", + "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } // Hand over to the GET handler to return the last insertion. @@ -281,27 +301,31 @@ func handleUpdateCampaign(c echo.Context) error { ) if id < 1 { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) + } var cm models.Campaign if err := app.queries.GetCampaign.Get(&cm, id, nil); err != nil { if err == sql.ErrNoRows { - return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.") + return echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}")) } app.log.Printf("error fetching campaign: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error fetching campaign: %s", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorFetching", + "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } if isCampaignalMutable(cm.Status) { - return echo.NewHTTPError(http.StatusBadRequest, - "Cannot update a running or a finished campaign.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.cantUpdate")) } - // Incoming params. - var o campaignReq + // Read the incoming params into the existing campaign fields from the DB. + // This allows updating of values that have been sent where as fields + // that are not in the request retain the old values. + o := campaignReq{Campaign: cm} if err := c.Bind(&o); err != nil { return err } @@ -317,6 +341,7 @@ func handleUpdateCampaign(c echo.Context) error { o.Subject, o.FromEmail, o.Body, + o.AltBody, o.ContentType, o.SendAt, o.SendLater, @@ -327,7 +352,8 @@ func handleUpdateCampaign(c echo.Context) error { if err != nil { app.log.Printf("error updating campaign: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error updating campaign: %s", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorUpdating", + "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } return handleGetCampaigns(c) @@ -341,18 +367,20 @@ func handleUpdateCampaignStatus(c echo.Context) error { ) if id < 1 { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } var cm models.Campaign if err := app.queries.GetCampaign.Get(&cm, id, nil); err != nil { if err == sql.ErrNoRows { - return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.") + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.message.notFound", "name", "{globals.terms.campaign}")) } app.log.Printf("error fetching campaign: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error fetching campaign: %s", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorFetching", + "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } // Incoming params. @@ -365,27 +393,27 @@ func handleUpdateCampaignStatus(c echo.Context) error { switch o.Status { case models.CampaignStatusDraft: if cm.Status != models.CampaignStatusScheduled { - errMsg = "Only scheduled campaigns can be saved as drafts" + errMsg = app.i18n.T("campaigns.onlyScheduledAsDraft") } case models.CampaignStatusScheduled: if cm.Status != models.CampaignStatusDraft { - errMsg = "Only draft campaigns can be scheduled" + errMsg = app.i18n.T("campaigns.onlyDraftAsScheduled") } if !cm.SendAt.Valid { - errMsg = "Campaign needs a `send_at` date to be scheduled" + errMsg = app.i18n.T("campaigns.needsSendAt") } case models.CampaignStatusRunning: if cm.Status != models.CampaignStatusPaused && cm.Status != models.CampaignStatusDraft { - errMsg = "Only paused campaigns and drafts can be started" + errMsg = app.i18n.T("campaigns.onlyPausedDraft") } case models.CampaignStatusPaused: if cm.Status != models.CampaignStatusRunning { - errMsg = "Only active campaigns can be paused" + errMsg = app.i18n.T("campaigns.onlyActivePause") } case models.CampaignStatusCancelled: if cm.Status != models.CampaignStatusRunning && cm.Status != models.CampaignStatusPaused { - errMsg = "Only active campaigns can be cancelled" + errMsg = app.i18n.T("campaigns.onlyActiveCancel") } } @@ -396,12 +424,16 @@ func handleUpdateCampaignStatus(c echo.Context) error { res, err := app.queries.UpdateCampaignStatus.Exec(cm.ID, o.Status) if err != nil { app.log.Printf("error updating campaign status: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error updating campaign status: %s", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorUpdating", + "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } if n, _ := res.RowsAffected(); n == 0 { - return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.") + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.notFound", + "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } return handleGetCampaigns(c) @@ -416,24 +448,29 @@ func handleDeleteCampaign(c echo.Context) error { ) if id < 1 { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } var cm models.Campaign if err := app.queries.GetCampaign.Get(&cm, id, nil); err != nil { if err == sql.ErrNoRows { - return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.") + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.notFound", + "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } app.log.Printf("error fetching campaign: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error fetching campaign: %s", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorFetching", + "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } if _, err := app.queries.DeleteCampaign.Exec(cm.ID); err != nil { app.log.Printf("error deleting campaign: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error deleting campaign: %v", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorDeleting", + "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) + } return c.JSON(http.StatusOK, okResp{true}) @@ -453,7 +490,8 @@ func handleGetRunningCampaignStats(c echo.Context) error { app.log.Printf("error fetching campaign stats: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error fetching campaign stats: %s", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorFetching", + "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } else if len(out) == 0 { return c.JSON(http.StatusOK, okResp{[]struct{}{}}) } @@ -488,7 +526,7 @@ func handleTestCampaign(c echo.Context) error { ) if campID < 1 { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid campaign ID.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.errorID")) } // Get and validate fields. @@ -503,7 +541,7 @@ func handleTestCampaign(c echo.Context) error { req = c } if len(req.SubscriberEmails) == 0 { - return echo.NewHTTPError(http.StatusBadRequest, "No subscribers to target.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.noSubsToTest")) } // Get the subscribers. @@ -514,28 +552,33 @@ func handleTestCampaign(c echo.Context) error { if err := app.queries.GetSubscribersByEmails.Select(&subs, req.SubscriberEmails); err != nil { app.log.Printf("error fetching subscribers: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error fetching subscribers: %s", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorFetching", + "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) } else if len(subs) == 0 { - return echo.NewHTTPError(http.StatusBadRequest, "No known subscribers given.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.noKnownSubsToTest")) } // The campaign. var camp models.Campaign if err := app.queries.GetCampaignForPreview.Get(&camp, campID); err != nil { if err == sql.ErrNoRows { - return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.") + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.notFound", + "name", "{globals.terms.campaign}")) } app.log.Printf("error fetching campaign: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error fetching campaign: %s", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorFetching", + "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } - // Override certain values in the DB with incoming values. + // Override certain values from the DB with incoming values. camp.Name = req.Name camp.Subject = req.Subject camp.FromEmail = req.FromEmail camp.Body = req.Body + camp.AltBody = req.AltBody camp.Messenger = req.Messenger camp.ContentType = req.ContentType camp.TemplateID = req.TemplateID @@ -544,8 +587,9 @@ func handleTestCampaign(c echo.Context) error { for _, s := range subs { sub := s if err := sendTestMessage(sub, &camp, app); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, - fmt.Sprintf("Error sending test: %v", err)) + app.log.Printf("error sending test message: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts("campaigns.errorSendTest", "error", err.Error())) } } @@ -556,15 +600,16 @@ func handleTestCampaign(c echo.Context) error { func sendTestMessage(sub models.Subscriber, camp *models.Campaign, app *App) error { if err := camp.CompileTemplate(app.manager.TemplateFuncs(camp)); err != nil { app.log.Printf("error compiling template: %v", err) - return fmt.Errorf("Error compiling template: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts("templates.errorCompiling", "error", err.Error())) } // Render the message body. m := app.manager.NewCampaignMessage(camp, sub) if err := m.Render(); err != nil { app.log.Printf("error rendering message: %v", err) - return echo.NewHTTPError(http.StatusBadRequest, - fmt.Sprintf("Error rendering message: %v", err)) + return echo.NewHTTPError(http.StatusNotFound, + app.i18n.Ts("templates.errorRendering", "error", err.Error())) } return app.messengers[camp.Messenger].Push(messenger.Message{ @@ -573,6 +618,7 @@ func sendTestMessage(sub models.Subscriber, camp *models.Campaign, app *App) err Subject: m.Subject(), ContentType: camp.ContentType, Body: m.Body(), + AltBody: m.AltBody(), Subscriber: sub, Campaign: camp, }) @@ -584,15 +630,15 @@ func validateCampaignFields(c campaignReq, app *App) (campaignReq, error) { c.FromEmail = app.constants.FromEmail } else if !regexFromAddress.Match([]byte(c.FromEmail)) { if !subimporter.IsEmail(c.FromEmail) { - return c, errors.New("invalid `from_email`") + return c, errors.New(app.i18n.T("campaigns.fieldInvalidFromEmail")) } } if !strHasLen(c.Name, 1, stdInputMaxLen) { - return c, errors.New("invalid length for `name`") + return c, errors.New(app.i18n.T("campaigns.fieldInvalidName")) } if !strHasLen(c.Subject, 1, stdInputMaxLen) { - return c, errors.New("invalid length for `subject`") + return c, errors.New(app.i18n.T("campaigns.fieldInvalidSubject")) } // if !hasLen(c.Body, 1, bodyMaxLen) { @@ -602,21 +648,21 @@ func validateCampaignFields(c campaignReq, app *App) (campaignReq, error) { // If there's a "send_at" date, it should be in the future. if c.SendAt.Valid { if c.SendAt.Time.Before(time.Now()) { - return c, errors.New("`send_at` date should be in the future") + return c, errors.New(app.i18n.T("campaigns.fieldInvalidSendAt")) } } if len(c.ListIDs) == 0 { - return c, errors.New("no lists selected") + return c, errors.New(app.i18n.T("campaigns.fieldInvalidListIDs")) } if !app.manager.HasMessenger(c.Messenger) { - return c, fmt.Errorf("unknown messenger %s", c.Messenger) + return c, errors.New(app.i18n.Ts("campaigns.fieldInvalidMessenger", "name", c.Messenger)) } camp := models.Campaign{Body: c.Body, TemplateBody: tplTag} if err := c.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil { - return c, fmt.Errorf("error compiling campaign body: %v", err) + return c, errors.New(app.i18n.Ts("campaigns.fieldInvalidBody", "error", err.Error())) } return c, nil @@ -633,7 +679,7 @@ func isCampaignalMutable(status string) bool { // makeOptinCampaignMessage makes a default opt-in campaign message body. func makeOptinCampaignMessage(o campaignReq, app *App) (campaignReq, error) { if len(o.ListIDs) == 0 { - return o, echo.NewHTTPError(http.StatusBadRequest, "Invalid list IDs.") + return o, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.fieldInvalidListIDs")) } // Fetch double opt-in lists from the given list IDs. @@ -642,13 +688,13 @@ func makeOptinCampaignMessage(o campaignReq, app *App) (campaignReq, error) { if err != nil { app.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err)) return o, echo.NewHTTPError(http.StatusInternalServerError, - "Error fetching opt-in lists.") + app.i18n.Ts("globals.messages.errorFetching", + "name", "{globals.terms.list}", "error", pqErrMsg(err))) } // No opt-in lists. if len(lists) == 0 { - return o, echo.NewHTTPError(http.StatusBadRequest, - "No opt-in lists found to create campaign.") + return o, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.noOptinLists")) } // Construct the opt-in URL with list IDs. @@ -666,8 +712,8 @@ func makeOptinCampaignMessage(o campaignReq, app *App) (campaignReq, error) { OptinURLAttr template.HTMLAttr }{lists, optinURLAttr}); err != nil { app.log.Printf("error compiling 'optin-campaign' template: %v", err) - return o, echo.NewHTTPError(http.StatusInternalServerError, - "Error compiling opt-in campaign template.") + return o, echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("templates.errorCompiling", "error", err.Error())) } o.Body = b.String() diff --git a/cmd/handlers.go b/cmd/handlers.go index b699854..a399460 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -2,6 +2,8 @@ package main import ( "crypto/subtle" + "encoding/json" + "fmt" "net/http" "net/url" "regexp" @@ -31,7 +33,10 @@ type pagination struct { Limit int `json:"limit"` } -var reUUID = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") +var ( + reUUID = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") + reLangCode = regexp.MustCompile("[^a-zA-Z_0-9]") +) // registerHandlers registers HTTP handlers. func registerHTTPHandlers(e *echo.Echo) { @@ -40,6 +45,7 @@ func registerHTTPHandlers(e *echo.Echo) { g.GET("/", handleIndexPage) g.GET("/api/health", handleHealthCheck) g.GET("/api/config.js", handleGetConfigScript) + g.GET("/api/lang/:lang", handleLoadLanguage) g.GET("/api/dashboard/charts", handleGetDashboardCharts) g.GET("/api/dashboard/counts", handleGetDashboardCounts) @@ -66,6 +72,8 @@ func registerHTTPHandlers(e *echo.Echo) { g.PUT("/api/subscribers/query/blocklist", handleBlocklistSubscribersByQuery) g.PUT("/api/subscribers/query/lists", handleManageSubscriberListsByQuery) g.GET("/api/subscribers", handleQuerySubscribers) + g.GET("/api/subscribers/export", + middleware.GzipWithConfig(middleware.GzipConfig{Level: 9})(handleExportSubscribers)) g.GET("/api/import/subscribers", handleGetImportSubscribers) g.GET("/api/import/subscribers/logs", handleGetImportSubscriberStats) @@ -83,6 +91,7 @@ func registerHTTPHandlers(e *echo.Echo) { g.GET("/api/campaigns/:id", handleGetCampaigns) g.GET("/api/campaigns/:id/preview", handlePreviewCampaign) g.POST("/api/campaigns/:id/preview", handlePreviewCampaign) + g.POST("/api/campaigns/:id/text", handlePreviewCampaign) g.POST("/api/campaigns/:id/test", handleTestCampaign) g.POST("/api/campaigns", handleCreateCampaign) g.PUT("/api/campaigns/:id", handleUpdateCampaign) @@ -117,6 +126,7 @@ func registerHTTPHandlers(e *echo.Echo) { g.GET("/settings/logs", handleIndexPage) // Public subscriber facing views. + e.GET("/subscription/form", handleSubscriptionFormPage) e.POST("/subscription/form", handleSubscriptionForm) e.GET("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage), "campUUID", "subUUID")) @@ -154,6 +164,23 @@ func handleHealthCheck(c echo.Context) error { return c.JSON(http.StatusOK, okResp{true}) } +// handleLoadLanguage returns the JSON language pack given the language code. +func handleLoadLanguage(c echo.Context) error { + app := c.Get("app").(*App) + + lang := c.Param("lang") + if len(lang) > 6 || reLangCode.MatchString(lang) { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid language code.") + } + + b, err := app.fs.Read(fmt.Sprintf("/lang/%s.json", lang)) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Unknown language.") + } + + return c.JSON(http.StatusOK, okResp{json.RawMessage(b)}) +} + // basicAuth middleware does an HTTP BasicAuth authentication for admin handlers. func basicAuth(username, password string, c echo.Context) (bool, error) { app := c.Get("app").(*App) @@ -174,11 +201,13 @@ func basicAuth(username, password string, c echo.Context) (bool, error) { // validateUUID middleware validates the UUID string format for a given set of params. func validateUUID(next echo.HandlerFunc, params ...string) echo.HandlerFunc { return func(c echo.Context) error { + app := c.Get("app").(*App) + for _, p := range params { if !reUUID.MatchString(c.Param(p)) { return c.Render(http.StatusBadRequest, tplMessage, - makeMsgTpl("Invalid request", "", - `One or more UUIDs in the request are invalid.`)) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", + app.i18n.T("globals.messages.invalidUUID"))) } } return next(c) @@ -198,14 +227,14 @@ func subscriberExists(next echo.HandlerFunc, params ...string) echo.HandlerFunc if err := app.queries.SubscriberExists.Get(&exists, 0, subUUID); err != nil { app.log.Printf("error checking subscriber existence: %v", err) return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl("Error", "", - `Error processing request. Please retry.`)) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", + app.i18n.T("public.errorProcessingRequest"))) } if !exists { - return c.Render(http.StatusBadRequest, tplMessage, - makeMsgTpl("Not found", "", - `Subscription not found.`)) + return c.Render(http.StatusNotFound, tplMessage, + makeMsgTpl(app.i18n.T("public.notFoundTitle"), "", + app.i18n.T("public.subNotFound"))) } return next(c) } diff --git a/cmd/i18n.go b/cmd/i18n.go new file mode 100644 index 0000000..f91b2f8 --- /dev/null +++ b/cmd/i18n.go @@ -0,0 +1,44 @@ +package main + +import ( + "encoding/json" + "fmt" +) + +type i18nLang struct { + Code string `json:"code"` + Name string `json:"name"` +} + +type i18nLangRaw struct { + Code string `json:"_.code"` + Name string `json:"_.name"` +} + +// geti18nLangList returns the list of available i18n languages. +func geti18nLangList(lang string, app *App) ([]i18nLang, error) { + list, err := app.fs.Glob("/i18n/*.json") + if err != nil { + return nil, err + } + + var out []i18nLang + for _, l := range list { + b, err := app.fs.Get(l) + if err != nil { + return out, fmt.Errorf("error reading lang file: %s: %v", l, err) + } + + var lang i18nLangRaw + if err := json.Unmarshal(b.ReadBytes(), &lang); err != nil { + return out, fmt.Errorf("error parsing lang file: %s: %v", l, err) + } + + out = append(out, i18nLang{ + Code: lang.Code, + Name: lang.Name, + }) + } + + return out, nil +} diff --git a/cmd/import.go b/cmd/import.go index d295970..5ebf12b 100644 --- a/cmd/import.go +++ b/cmd/import.go @@ -2,7 +2,6 @@ package main import ( "encoding/json" - "fmt" "io" "io/ioutil" "net/http" @@ -27,30 +26,28 @@ func handleImportSubscribers(c echo.Context) error { // Is an import already running? if app.importer.GetStats().Status == subimporter.StatusImporting { - return echo.NewHTTPError(http.StatusBadRequest, - "An import is already running. Wait for it to finish or stop it before trying again.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("import.alreadyRunning")) } // Unmarsal the JSON params. var r reqImport if err := json.Unmarshal([]byte(c.FormValue("params")), &r); err != nil { return echo.NewHTTPError(http.StatusBadRequest, - fmt.Sprintf("Invalid `params` field: %v", err)) + app.i18n.Ts("import.invalidParams", "error", err.Error())) } if r.Mode != subimporter.ModeSubscribe && r.Mode != subimporter.ModeBlocklist { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid `mode`") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("import.invalidMode")) } if len(r.Delim) != 1 { - return echo.NewHTTPError(http.StatusBadRequest, - "`delim` should be a single character") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("import.invalidDelim")) } file, err := c.FormFile("file") if err != nil { return echo.NewHTTPError(http.StatusBadRequest, - fmt.Sprintf("Invalid `file`: %v", err)) + app.i18n.Ts("import.invalidFile", "error", err.Error())) } src, err := file.Open() @@ -62,20 +59,20 @@ func handleImportSubscribers(c echo.Context) error { out, err := ioutil.TempFile("", "listmonk") if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error copying uploaded file: %v", err)) + app.i18n.Ts("import.errorCopyingFile", "error", err.Error())) } defer out.Close() if _, err = io.Copy(out, src); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error copying uploaded file: %v", err)) + app.i18n.Ts("import.errorCopyingFile", "error", err.Error())) } // Start the importer session. impSess, err := app.importer.NewSession(file.Filename, r.Mode, r.Overwrite, r.ListIDs) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, - fmt.Sprintf("Error starting import session: %v", err)) + return echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts("import.errorStarting", "error", err.Error())) } go impSess.Start() @@ -91,7 +88,7 @@ func handleImportSubscribers(c echo.Context) error { dir, files, err := impSess.ExtractZIP(out.Name(), 1) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error processing ZIP file: %v", err)) + app.i18n.Ts("import.errorProcessingZIP", "error", err.Error())) } go impSess.LoadCSV(dir+"/"+files[0], rune(r.Delim[0])) } diff --git a/cmd/init.go b/cmd/init.go index 3effbc3..fab4fee 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -20,6 +20,7 @@ import ( "github.com/knadh/koanf/providers/confmap" "github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/providers/posflag" + "github.com/knadh/listmonk/internal/i18n" "github.com/knadh/listmonk/internal/manager" "github.com/knadh/listmonk/internal/media" "github.com/knadh/listmonk/internal/media/providers/filesystem" @@ -39,12 +40,15 @@ const ( // constants contains static, constant config values required by the app. type constants struct { - RootURL string `koanf:"root_url"` - LogoURL string `koanf:"logo_url"` - FaviconURL string `koanf:"favicon_url"` - FromEmail string `koanf:"from_email"` - NotifyEmails []string `koanf:"notify_emails"` - Privacy struct { + RootURL string `koanf:"root_url"` + LogoURL string `koanf:"logo_url"` + FaviconURL string `koanf:"favicon_url"` + FromEmail string `koanf:"from_email"` + NotifyEmails []string `koanf:"notify_emails"` + EnablePublicSubPage bool `koanf:"enable_public_subscription_page"` + Lang string `koanf:"lang"` + DBBatchSize int `koanf:"batch_size"` + Privacy struct { IndividualTracking bool `koanf:"individual_tracking"` AllowBlocklist bool `koanf:"allow_blocklist"` AllowExport bool `koanf:"allow_export"` @@ -131,6 +135,7 @@ func initFS(staticDir string) stuffbin.FileSystem { // Alias all files inside dist/ and dist/frontend to frontend/*. "frontend/dist/favicon.png:/frontend/favicon.png", "frontend/dist/frontend:/frontend", + "i18n:/i18n", } fs, err = stuffbin.NewLocalFS("/", files...) @@ -230,6 +235,7 @@ func initConstants() *constants { } c.RootURL = strings.TrimRight(c.RootURL, "/") + c.Lang = ko.String("app.lang") c.Privacy.Exportable = maps.StringSliceToLookupMap(ko.Strings("privacy.exportable")) c.MediaProvider = ko.String("upload.provider") @@ -251,6 +257,36 @@ func initConstants() *constants { return &c } +// initI18n initializes a new i18n instance with the selected language map +// loaded from the filesystem. English is a loaded first as the default map +// and then the selected language is loaded on top of it so that if there are +// missing translations in it, the default English translations show up. +func initI18n(lang string, fs stuffbin.FileSystem) *i18n.I18n { + const def = "en" + + b, err := fs.Read(fmt.Sprintf("/i18n/%s.json", def)) + if err != nil { + lo.Fatalf("error reading default i18n language file: %s: %v", def, err) + } + + // Initialize with the default language. + i, err := i18n.New(b) + if err != nil { + lo.Fatalf("error unmarshalling i18n language: %v", err) + } + + // Load the selected language on top of it. + b, err = fs.Read(fmt.Sprintf("/i18n/%s.json", lang)) + if err != nil { + lo.Fatalf("error reading i18n language file: %v", err) + } + if err := i.Load(b); err != nil { + lo.Fatalf("error loading i18n language file: %v", err) + } + + return i +} + // initCampaignManager initializes the campaign manager. func initCampaignManager(q *Queries, cs *constants, app *App) *manager.Manager { campNotifCB := func(subject string, data interface{}) error { @@ -265,19 +301,22 @@ func initCampaignManager(q *Queries, cs *constants, app *App) *manager.Manager { } return manager.New(manager.Config{ - BatchSize: ko.Int("app.batch_size"), - Concurrency: ko.Int("app.concurrency"), - MessageRate: ko.Int("app.message_rate"), - MaxSendErrors: ko.Int("app.max_send_errors"), - FromEmail: cs.FromEmail, - IndividualTracking: ko.Bool("privacy.individual_tracking"), - UnsubURL: cs.UnsubURL, - OptinURL: cs.OptinURL, - LinkTrackURL: cs.LinkTrackURL, - ViewTrackURL: cs.ViewTrackURL, - MessageURL: cs.MessageURL, - UnsubHeader: ko.Bool("privacy.unsubscribe_header"), - }, newManagerDB(q), campNotifCB, lo) + BatchSize: ko.Int("app.batch_size"), + Concurrency: ko.Int("app.concurrency"), + MessageRate: ko.Int("app.message_rate"), + MaxSendErrors: ko.Int("app.max_send_errors"), + FromEmail: cs.FromEmail, + IndividualTracking: ko.Bool("privacy.individual_tracking"), + UnsubURL: cs.UnsubURL, + OptinURL: cs.OptinURL, + LinkTrackURL: cs.LinkTrackURL, + ViewTrackURL: cs.ViewTrackURL, + MessageURL: cs.MessageURL, + UnsubHeader: ko.Bool("privacy.unsubscribe_header"), + SlidingWindow: ko.Bool("app.message_sliding_window"), + SlidingWindowDuration: ko.Duration("app.message_sliding_window_duration"), + SlidingWindowRate: ko.Int("app.message_sliding_window_rate"), + }, newManagerDB(q), campNotifCB, app.i18n, lo) } @@ -407,7 +446,7 @@ func initMediaStore() media.Store { // initNotifTemplates compiles and returns e-mail notification templates that are // used for sending ad-hoc notifications to admins and subscribers. -func initNotifTemplates(path string, fs stuffbin.FileSystem, cs *constants) *template.Template { +func initNotifTemplates(path string, fs stuffbin.FileSystem, i *i18n.I18n, cs *constants) *template.Template { // Register utility functions that the e-mail templates can use. funcs := template.FuncMap{ "RootURL": func() string { @@ -415,7 +454,11 @@ func initNotifTemplates(path string, fs stuffbin.FileSystem, cs *constants) *tem }, "LogoURL": func() string { return cs.LogoURL - }} + }, + "L": func() *i18n.I18n { + return i + }, + } tpl, err := stuffbin.ParseTemplatesGlob(funcs, fs, "/static/email-templates/*.html") if err != nil { @@ -439,7 +482,10 @@ func initHTTPServer(app *App) *echo.Echo { }) // Parse and load user facing templates. - tpl, err := stuffbin.ParseTemplatesGlob(nil, app.fs, "/public/templates/*.html") + tpl, err := stuffbin.ParseTemplatesGlob(template.FuncMap{ + "L": func() *i18n.I18n { + return app.i18n + }}, app.fs, "/public/templates/*.html") if err != nil { lo.Fatalf("error parsing public templates: %v", err) } diff --git a/cmd/install.go b/cmd/install.go index 5695a35..fbfb9a8 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -96,15 +96,15 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt bool) { } // Default template. - tplBody, err := ioutil.ReadFile("static/email-templates/default.tpl") + tplBody, err := fs.Get("/static/email-templates/default.tpl") if err != nil { - tplBody = []byte(tplTag) + lo.Fatalf("error reading default e-mail template: %v", err) } var tplID int if err := q.CreateTemplate.Get(&tplID, "Default template", - string(tplBody), + string(tplBody.ReadBytes()), ); err != nil { lo.Fatalf("error creating default template: %v", err) } @@ -120,6 +120,7 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt bool) { "No Reply ", `

Hi {{ .Subscriber.FirstName }}!

This is a test e-mail campaign. Your second name is {{ .Subscriber.LastName }} and you are from {{ .Subscriber.Attribs.city }}.`, + nil, "richtext", nil, pq.StringArray{"test-campaign"}, diff --git a/cmd/lists.go b/cmd/lists.go index 9c3c20a..2bda82c 100644 --- a/cmd/lists.go +++ b/cmd/lists.go @@ -50,13 +50,15 @@ func handleGetLists(c echo.Context) error { order = sortAsc } - if err := db.Select(&out.Results, fmt.Sprintf(app.queries.GetLists, orderBy, order), listID, pg.Offset, pg.Limit); err != nil { + if err := db.Select(&out.Results, fmt.Sprintf(app.queries.QueryLists, orderBy, order), listID, pg.Offset, pg.Limit); err != nil { app.log.Printf("error fetching lists: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error fetching lists: %s", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorFetching", + "name", "{globals.terms.lists}", "error", pqErrMsg(err))) } if single && len(out.Results) == 0 { - return echo.NewHTTPError(http.StatusBadRequest, "List not found.") + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.list}")) } if len(out.Results) == 0 { return c.JSON(http.StatusOK, okResp{[]struct{}{}}) @@ -93,14 +95,14 @@ func handleCreateList(c echo.Context) error { // Validate. if !strHasLen(o.Name, 1, stdInputMaxLen) { - return echo.NewHTTPError(http.StatusBadRequest, - "Invalid length for the name field.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("lists.invalidName")) } uu, err := uuid.NewV4() if err != nil { app.log.Printf("error generating UUID: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, "Error generating UUID") + return echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts("globals.messages.errorUUID", "error", err.Error())) } // Insert and read ID. @@ -114,7 +116,8 @@ func handleCreateList(c echo.Context) error { pq.StringArray(normalizeTags(o.Tags))); err != nil { app.log.Printf("error creating list: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error creating list: %s", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorCreating", + "name", "{globals.terms.list}", "error", pqErrMsg(err))) } // Hand over to the GET handler to return the last insertion. @@ -131,7 +134,7 @@ func handleUpdateList(c echo.Context) error { ) if id < 1 { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } // Incoming params. @@ -144,12 +147,14 @@ func handleUpdateList(c echo.Context) error { o.Name, o.Type, o.Optin, pq.StringArray(normalizeTags(o.Tags))) if err != nil { app.log.Printf("error updating list: %v", err) - return echo.NewHTTPError(http.StatusBadRequest, - fmt.Sprintf("Error updating list: %s", pqErrMsg(err))) + return echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts("globals.messages.errorUpdating", + "name", "{globals.terms.list}", "error", pqErrMsg(err))) } if n, _ := res.RowsAffected(); n == 0 { - return echo.NewHTTPError(http.StatusBadRequest, "List not found.") + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.list}")) } return handleGetLists(c) @@ -165,7 +170,7 @@ func handleDeleteLists(c echo.Context) error { ) if id < 1 && len(ids) == 0 { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } if id > 0 { @@ -175,7 +180,8 @@ func handleDeleteLists(c echo.Context) error { if _, err := app.queries.DeleteLists.Exec(ids); err != nil { app.log.Printf("error deleting lists: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error deleting: %v", err)) + app.i18n.Ts("globals.messages.errorDeleting", + "name", "{globals.terms.list}", "error", pqErrMsg(err))) } return c.JSON(http.StatusOK, okResp{true}) diff --git a/cmd/main.go b/cmd/main.go index c2d2b4d..7e11afa 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -17,6 +17,7 @@ import ( "github.com/knadh/koanf" "github.com/knadh/koanf/providers/env" "github.com/knadh/listmonk/internal/buflog" + "github.com/knadh/listmonk/internal/i18n" "github.com/knadh/listmonk/internal/manager" "github.com/knadh/listmonk/internal/media" "github.com/knadh/listmonk/internal/messenger" @@ -39,6 +40,7 @@ type App struct { importer *subimporter.Importer messengers map[string]messenger.Messenger media media.Store + i18n *i18n.I18n notifTpls *template.Template log *log.Logger bufLog *buflog.BufLog @@ -148,10 +150,14 @@ func main() { log: lo, bufLog: bufLog, } + + // Load i18n language map. + app.i18n = initI18n(app.constants.Lang, fs) + _, app.queries = initQueries(queryFilePath, db, fs, true) app.manager = initCampaignManager(app.queries, app.constants, app) app.importer = initImporter(app.queries, db, app) - app.notifTpls = initNotifTemplates("/email-templates/*.html", fs, app.constants) + app.notifTpls = initNotifTemplates("/email-templates/*.html", fs, app.i18n, app.constants) // Initialize the default SMTP (`email`) messenger. app.messengers[emailMsgr] = initSMTPMessenger(app.manager) diff --git a/cmd/media.go b/cmd/media.go index c34cbd4..87578e6 100644 --- a/cmd/media.go +++ b/cmd/media.go @@ -2,7 +2,6 @@ package main import ( "bytes" - "fmt" "mime/multipart" "net/http" "strconv" @@ -35,14 +34,14 @@ func handleUploadMedia(c echo.Context) error { file, err := c.FormFile("file") if err != nil { return echo.NewHTTPError(http.StatusBadRequest, - fmt.Sprintf("Invalid file uploaded: %v", err)) + app.i18n.Ts("media.invalidFile", "error", err.Error())) } // Validate MIME type with the list of allowed types. var typ = file.Header.Get("Content-type") if ok := validateMIME(typ, imageMimes); !ok { return echo.NewHTTPError(http.StatusBadRequest, - fmt.Sprintf("Unsupported file type (%s) uploaded.", typ)) + app.i18n.Ts("media.unsupportedFileType", "type", typ)) } // Generate filename @@ -51,8 +50,8 @@ func handleUploadMedia(c echo.Context) error { // Read file contents in memory src, err := file.Open() if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, - fmt.Sprintf("Error reading file: %s", err)) + return echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts("media.errorReadingFile", "error", err.Error())) } defer src.Close() @@ -62,7 +61,7 @@ func handleUploadMedia(c echo.Context) error { app.log.Printf("error uploading file: %v", err) cleanUp = true return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error uploading file: %s", err)) + app.i18n.Ts("media.errorUploading", "error", err.Error())) } defer func() { @@ -80,7 +79,7 @@ func handleUploadMedia(c echo.Context) error { cleanUp = true app.log.Printf("error resizing image: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error resizing image: %s", err)) + app.i18n.Ts("media.errorResizing", "error", err.Error())) } // Upload thumbnail. @@ -89,13 +88,14 @@ func handleUploadMedia(c echo.Context) error { cleanUp = true app.log.Printf("error saving thumbnail: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error saving thumbnail: %s", err)) + app.i18n.Ts("media.errorSavingThumbnail", "error", err.Error())) } uu, err := uuid.NewV4() if err != nil { app.log.Printf("error generating UUID: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, "Error generating UUID") + return echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts("globals.messages.errorUUID", "error", err.Error())) } // Write to the DB. @@ -103,7 +103,8 @@ func handleUploadMedia(c echo.Context) error { cleanUp = true app.log.Printf("error inserting uploaded file to db: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error saving uploaded file to db: %s", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorCreating", + "name", "{globals.terms.media}", "error", pqErrMsg(err))) } return c.JSON(http.StatusOK, okResp{true}) } @@ -117,7 +118,8 @@ func handleGetMedia(c echo.Context) error { if err := app.queries.GetMedia.Select(&out, app.constants.MediaProvider); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error fetching media list: %s", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorFetching", + "name", "{globals.terms.media}", "error", pqErrMsg(err))) } for i := 0; i < len(out); i++ { @@ -136,13 +138,14 @@ func handleDeleteMedia(c echo.Context) error { ) if id < 1 { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } var m media.Media if err := app.queries.DeleteMedia.Get(&m, id); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error deleting media: %s", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorDeleting", + "name", "{globals.terms.media}", "error", pqErrMsg(err))) } app.media.Delete(m.Filename) @@ -160,8 +163,7 @@ func createThumbnail(file *multipart.FileHeader) (*bytes.Reader, error) { img, err := imaging.Decode(src) if err != nil { - return nil, echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error decoding image: %v", err)) + return nil, err } // Encode the image into a byte slice as PNG. diff --git a/cmd/public.go b/cmd/public.go index 1d488c0..fdecbb4 100644 --- a/cmd/public.go +++ b/cmd/public.go @@ -12,6 +12,7 @@ import ( "strconv" "strings" + "github.com/knadh/listmonk/internal/i18n" "github.com/knadh/listmonk/internal/messenger" "github.com/knadh/listmonk/internal/subimporter" "github.com/knadh/listmonk/models" @@ -38,6 +39,7 @@ type tplData struct { LogoURL string FaviconURL string Data interface{} + L *i18n.I18n } type publicTpl struct { @@ -66,6 +68,11 @@ type msgTpl struct { Message string } +type subFormTpl struct { + publicTpl + Lists []models.List +} + type subForm struct { subimporter.SubReq SubListUUIDs []string `form:"l"` @@ -82,6 +89,7 @@ func (t *tplRenderer) Render(w io.Writer, name string, data interface{}, c echo. LogoURL: t.LogoURL, FaviconURL: t.FaviconURL, Data: data, + L: c.Get("app").(*App).i18n, }) } @@ -99,12 +107,14 @@ func handleViewCampaignMessage(c echo.Context) error { if err := app.queries.GetCampaign.Get(&camp, 0, campUUID); err != nil { if err == sql.ErrNoRows { return c.Render(http.StatusNotFound, tplMessage, - makeMsgTpl("Not found", "", `The e-mail campaign was not found.`)) + makeMsgTpl(app.i18n.T("public.notFoundTitle"), "", + app.i18n.T("public.campaignNotFound"))) } app.log.Printf("error fetching campaign: %v", err) return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl("Error", "", `Error fetching e-mail campaign.`)) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", + app.i18n.Ts("public.errorFetchingCampaign"))) } // Get the subscriber. @@ -112,19 +122,22 @@ func handleViewCampaignMessage(c echo.Context) error { if err := app.queries.GetSubscriber.Get(&sub, 0, subUUID); err != nil { if err == sql.ErrNoRows { return c.Render(http.StatusNotFound, tplMessage, - makeMsgTpl("Not found", "", `The e-mail message was not found.`)) + makeMsgTpl(app.i18n.T("public.notFoundTitle"), "", + app.i18n.T("public.errorFetchingEmail"))) } app.log.Printf("error fetching campaign subscriber: %v", err) return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl("Error", "", `Error fetching e-mail message.`)) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", + app.i18n.Ts("public.errorFetchingCampaign"))) } // Compile the template. if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil { app.log.Printf("error compiling template: %v", err) return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl("Error", "", `Error compiling e-mail template.`)) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", + app.i18n.Ts("public.errorFetchingCampaign"))) } // Render the message body. @@ -132,7 +145,8 @@ func handleViewCampaignMessage(c echo.Context) error { if err := m.Render(); err != nil { app.log.Printf("error rendering message: %v", err) return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl("Error", "", `Error rendering e-mail message.`)) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", + app.i18n.Ts("public.errorFetchingCampaign"))) } return c.HTML(http.StatusOK, string(m.Body())) @@ -151,7 +165,7 @@ func handleSubscriptionPage(c echo.Context) error { out = unsubTpl{} ) out.SubUUID = subUUID - out.Title = "Unsubscribe from mailing list" + out.Title = app.i18n.T("public.unsubscribeTitle") out.AllowBlocklist = app.constants.Privacy.AllowBlocklist out.AllowExport = app.constants.Privacy.AllowExport out.AllowWipe = app.constants.Privacy.AllowWipe @@ -166,13 +180,13 @@ func handleSubscriptionPage(c echo.Context) error { if _, err := app.queries.Unsubscribe.Exec(campUUID, subUUID, blocklist); err != nil { app.log.Printf("error unsubscribing: %v", err) return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl("Error", "", - `Error processing request. Please retry.`)) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", + app.i18n.Ts("public.errorProcessingRequest"))) } return c.Render(http.StatusOK, tplMessage, - makeMsgTpl("Unsubscribed", "", - `You have been successfully unsubscribed.`)) + makeMsgTpl(app.i18n.T("public.unsubbedTitle"), "", + app.i18n.T("public.unsubbedInfo"))) } return c.Render(http.StatusOK, "subscription", out) @@ -189,7 +203,7 @@ func handleOptinPage(c echo.Context) error { out = optinTpl{} ) out.SubUUID = subUUID - out.Title = "Confirm subscriptions" + out.Title = app.i18n.T("public.confirmOptinSubTitle") out.SubUUID = subUUID // Get and validate fields. @@ -202,8 +216,8 @@ func handleOptinPage(c echo.Context) error { for _, l := range out.ListUUIDs { if !reUUID.MatchString(l) { return c.Render(http.StatusBadRequest, tplMessage, - makeMsgTpl("Invalid request", "", - `One or more UUIDs in the request are invalid.`)) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", + app.i18n.T("globals.messages.invalidUUID"))) } } } @@ -212,15 +226,17 @@ func handleOptinPage(c echo.Context) error { if err := app.queries.GetSubscriberLists.Select(&out.Lists, 0, subUUID, nil, pq.StringArray(out.ListUUIDs), models.SubscriptionStatusUnconfirmed, nil); err != nil { app.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err)) + return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl("Error", "", `Error fetching lists. Please retry.`)) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", + app.i18n.Ts("public.errorFetchingLists"))) } // There are no lists to confirm. if len(out.Lists) == 0 { return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl("No subscriptions", "", - `There are no subscriptions to confirm.`)) + makeMsgTpl(app.i18n.T("public.noSubTitle"), "", + app.i18n.Ts("public.noSubInfo"))) } // Confirm. @@ -228,17 +244,52 @@ func handleOptinPage(c echo.Context) error { if _, err := app.queries.ConfirmSubscriptionOptin.Exec(subUUID, pq.StringArray(out.ListUUIDs)); err != nil { app.log.Printf("error unsubscribing: %v", err) return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl("Error", "", - `Error processing request. Please retry.`)) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", + app.i18n.Ts("public.errorProcessingRequest"))) } + return c.Render(http.StatusOK, tplMessage, - makeMsgTpl("Confirmed", "", - `Your subscriptions have been confirmed.`)) + makeMsgTpl(app.i18n.T("public.subConfirmedTitle"), "", + app.i18n.Ts("public.subConfirmed"))) } return c.Render(http.StatusOK, "optin", out) } +// handleSubscriptionFormPage handles subscription requests coming from public +// HTML subscription forms. +func handleSubscriptionFormPage(c echo.Context) error { + var ( + app = c.Get("app").(*App) + ) + + if !app.constants.EnablePublicSubPage { + return c.Render(http.StatusNotFound, tplMessage, + makeMsgTpl(app.i18n.T("public.errorTitle"), "", + app.i18n.Ts("public.invalidFeature"))) + } + + // Get all public lists. + var lists []models.List + if err := app.queries.GetLists.Select(&lists, models.ListTypePublic); err != nil { + app.log.Printf("error fetching public lists for form: %s", pqErrMsg(err)) + return c.Render(http.StatusInternalServerError, tplMessage, + makeMsgTpl(app.i18n.T("public.errorTitle"), "", + app.i18n.Ts("public.errorFetchingLists"))) + } + + if len(lists) == 0 { + return c.Render(http.StatusInternalServerError, tplMessage, + makeMsgTpl(app.i18n.T("public.errorTitle"), "", + app.i18n.Ts("public.noListsAvailable"))) + } + + out := subFormTpl{} + out.Title = app.i18n.T("public.sub") + out.Lists = lists + return c.Render(http.StatusOK, "subscription-form", out) +} + // handleSubscriptionForm handles subscription requests coming from public // HTML subscription forms. func handleSubscriptionForm(c echo.Context) error { @@ -253,9 +304,9 @@ func handleSubscriptionForm(c echo.Context) error { } if len(req.SubListUUIDs) == 0 { - return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl("Error", "", - `No lists to subscribe to.`)) + return c.Render(http.StatusBadRequest, tplMessage, + makeMsgTpl(app.i18n.T("public.errorTitle"), "", + app.i18n.T("public.noListsSelected"))) } // If there's no name, use the name bit from the e-mail. @@ -267,19 +318,20 @@ func handleSubscriptionForm(c echo.Context) error { // Validate fields. if err := subimporter.ValidateFields(req.SubReq); err != nil { return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl("Error", "", err.Error())) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", err.Error())) } // Insert the subscriber into the DB. req.Status = models.SubscriberStatusEnabled req.ListUUIDs = pq.StringArray(req.SubListUUIDs) - if _, err := insertSubscriber(req.SubReq, app); err != nil { + if _, err := insertSubscriber(req.SubReq, app); err != nil && err != errSubscriberExists { return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl("Error", "", fmt.Sprintf("%s", err.(*echo.HTTPError).Message))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", fmt.Sprintf("%s", err.(*echo.HTTPError).Message))) } return c.Render(http.StatusOK, tplMessage, - makeMsgTpl("Done", "", `Subscribed successfully.`)) + makeMsgTpl(app.i18n.T("public.subTitle"), "", + app.i18n.Ts("public.subConfirmed"))) } // handleLinkRedirect redirects a link UUID to its original underlying link @@ -302,12 +354,14 @@ func handleLinkRedirect(c echo.Context) error { if err := app.queries.RegisterLinkClick.Get(&url, linkUUID, campUUID, subUUID); err != nil { if pqErr, ok := err.(*pq.Error); ok && pqErr.Column == "link_id" { return c.Render(http.StatusNotFound, tplMessage, - makeMsgTpl("Invalid link", "", "The requested link is invalid.")) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", + app.i18n.Ts("public.invalidLink"))) } app.log.Printf("error fetching redirect link: %s", err) return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl("Error opening link", "", "There was an error opening the link. Please try later.")) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", + app.i18n.Ts("public.errorProcessingRequest"))) } return c.Redirect(http.StatusTemporaryRedirect, url) @@ -352,7 +406,8 @@ func handleSelfExportSubscriberData(c echo.Context) error { // Is export allowed? if !app.constants.Privacy.AllowExport { return c.Render(http.StatusBadRequest, tplMessage, - makeMsgTpl("Invalid request", "", "The feature is not available.")) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", + app.i18n.Ts("public.invalidFeature"))) } // Get the subscriber's data. A single query that gets the profile, @@ -362,18 +417,17 @@ func handleSelfExportSubscriberData(c echo.Context) error { if err != nil { app.log.Printf("error exporting subscriber data: %s", err) return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl("Error processing request", "", - "There was an error processing your request. Please try later.")) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", + app.i18n.Ts("public.errorProcessingRequest"))) } // Prepare the attachment e-mail. var msg bytes.Buffer if err := app.notifTpls.ExecuteTemplate(&msg, notifSubscriberData, data); err != nil { - app.log.Printf("error compiling notification template '%s': %v", - notifSubscriberData, err) + app.log.Printf("error compiling notification template '%s': %v", notifSubscriberData, err) return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl("Error preparing data", "", - "There was an error preparing your data. Please try later.")) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", + app.i18n.Ts("public.errorProcessingRequest"))) } // Send the data as a JSON attachment to the subscriber. @@ -393,12 +447,13 @@ func handleSelfExportSubscriberData(c echo.Context) error { }); err != nil { app.log.Printf("error e-mailing subscriber profile: %s", err) return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl("Error e-mailing data", "", - "There was an error e-mailing your data. Please try later.")) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", + app.i18n.Ts("public.errorProcessingRequest"))) } + return c.Render(http.StatusOK, tplMessage, - makeMsgTpl("Data e-mailed", "", - `Your data has been e-mailed to you as an attachment.`)) + makeMsgTpl(app.i18n.T("public.dataSentTitle"), "", + app.i18n.T("public.dataSent"))) } // handleWipeSubscriberData allows a subscriber to delete their data. The @@ -413,20 +468,20 @@ func handleWipeSubscriberData(c echo.Context) error { // Is wiping allowed? if !app.constants.Privacy.AllowWipe { return c.Render(http.StatusBadRequest, tplMessage, - makeMsgTpl("Invalid request", "", - "The feature is not available.")) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", + app.i18n.Ts("public.invalidFeature"))) } if _, err := app.queries.DeleteSubscribers.Exec(nil, pq.StringArray{subUUID}); err != nil { app.log.Printf("error wiping subscriber data: %s", err) return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl("Error processing request", "", - "There was an error processing your request. Please try later.")) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", + app.i18n.Ts("public.errorProcessingRequest"))) } return c.Render(http.StatusOK, tplMessage, - makeMsgTpl("Data removed", "", - `Your subscriptions and all associated data has been removed.`)) + makeMsgTpl(app.i18n.T("public.dataRemovedTitle"), "", + app.i18n.T("public.dataRemoved"))) } // drawTransparentImage draws a transparent PNG of given dimensions diff --git a/cmd/queries.go b/cmd/queries.go index bf2edc4..e6f692f 100644 --- a/cmd/queries.go +++ b/cmd/queries.go @@ -35,6 +35,7 @@ type Queries struct { // Non-prepared arbitrary subscriber queries. QuerySubscribers string `query:"query-subscribers"` + QuerySubscribersForExport string `query:"query-subscribers-for-export"` QuerySubscribersTpl string `query:"query-subscribers-template"` DeleteSubscribersByQuery string `query:"delete-subscribers-by-query"` AddSubscribersToListsByQuery string `query:"add-subscribers-to-lists-by-query"` @@ -43,7 +44,8 @@ type Queries struct { UnsubscribeSubscribersFromListsByQuery string `query:"unsubscribe-subscribers-from-lists-by-query"` CreateList *sqlx.Stmt `query:"create-list"` - GetLists string `query:"get-lists"` + QueryLists string `query:"query-lists"` + GetLists *sqlx.Stmt `query:"get-lists"` GetListsByOptin *sqlx.Stmt `query:"get-lists-by-optin"` UpdateList *sqlx.Stmt `query:"update-list"` UpdateListsDate *sqlx.Stmt `query:"update-lists-date"` diff --git a/cmd/settings.go b/cmd/settings.go index ae416dc..5b2af27 100644 --- a/cmd/settings.go +++ b/cmd/settings.go @@ -2,7 +2,6 @@ package main import ( "encoding/json" - "fmt" "net/http" "regexp" "strings" @@ -15,15 +14,22 @@ import ( ) type settings struct { - AppRootURL string `json:"app.root_url"` - AppLogoURL string `json:"app.logo_url"` - AppFaviconURL string `json:"app.favicon_url"` - AppFromEmail string `json:"app.from_email"` - AppNotifyEmails []string `json:"app.notify_emails"` - AppBatchSize int `json:"app.batch_size"` - AppConcurrency int `json:"app.concurrency"` - AppMaxSendErrors int `json:"app.max_send_errors"` - AppMessageRate int `json:"app.message_rate"` + AppRootURL string `json:"app.root_url"` + AppLogoURL string `json:"app.logo_url"` + AppFaviconURL string `json:"app.favicon_url"` + AppFromEmail string `json:"app.from_email"` + AppNotifyEmails []string `json:"app.notify_emails"` + EnablePublicSubPage bool `json:"app.enable_public_subscription_page"` + AppLang string `json:"app.lang"` + + AppBatchSize int `json:"app.batch_size"` + AppConcurrency int `json:"app.concurrency"` + AppMaxSendErrors int `json:"app.max_send_errors"` + AppMessageRate int `json:"app.message_rate"` + + AppMessageSlidingWindow bool `json:"app.message_sliding_window"` + AppMessageSlidingWindowDuration string `json:"app.message_sliding_window_duration"` + AppMessageSlidingWindowRate int `json:"app.message_sliding_window_rate"` PrivacyIndividualTracking bool `json:"privacy.individual_tracking"` PrivacyUnsubHeader bool `json:"privacy.unsubscribe_header"` @@ -144,8 +150,7 @@ func handleUpdateSettings(c echo.Context) error { } } if !has { - return echo.NewHTTPError(http.StatusBadRequest, - "At least one SMTP block should be enabled.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("settings.errorNoSMTP")) } // Validate and sanitize postback Messenger names. Duplicates are disallowed @@ -169,10 +174,10 @@ func handleUpdateSettings(c echo.Context) error { name := reAlphaNum.ReplaceAllString(strings.ToLower(m.Name), "") if _, ok := names[name]; ok { return echo.NewHTTPError(http.StatusBadRequest, - fmt.Sprintf("Duplicate messenger name `%s`.", name)) + app.i18n.Ts("settings.duplicateMessengerName", "name", name)) } if len(name) == 0 { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid messenger name.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("settings.invalidMessengerName")) } set.Messengers[i].Name = name @@ -188,13 +193,14 @@ func handleUpdateSettings(c echo.Context) error { b, err := json.Marshal(set) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error encoding settings: %v", err)) + app.i18n.Ts("settings.errorEncoding", "error", err.Error())) } // Update the settings in the DB. if _, err := app.queries.UpdateSettings.Exec(b); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error updating settings: %s", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorUpdating", + "name", "{globals.terms.settings}", "error", pqErrMsg(err))) } // If there are any active campaigns, don't do an auto reload and @@ -232,13 +238,14 @@ func getSettings(app *App) (settings, error) { if err := app.queries.GetSettings.Get(&b); err != nil { return out, echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error fetching settings: %s", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorFetching", + "name", "{globals.terms.settings}", "error", pqErrMsg(err))) } // Unmarshall the settings and filter out sensitive fields. if err := json.Unmarshal([]byte(b), &out); err != nil { return out, echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error parsing settings: %v", err)) + app.i18n.Ts("settings.errorEncoding", "error", err.Error())) } return out, nil diff --git a/cmd/subscribers.go b/cmd/subscribers.go index dbe461c..b36703c 100644 --- a/cmd/subscribers.go +++ b/cmd/subscribers.go @@ -3,7 +3,9 @@ package main import ( "context" "database/sql" + "encoding/csv" "encoding/json" + "errors" "fmt" "net/http" "net/url" @@ -66,6 +68,8 @@ var ( } subQuerySortFields = []string{"email", "name", "created_at", "updated_at"} + + errSubscriberExists = errors.New("subscriber already exists") ) // handleGetSubscriber handles the retrieval of a single subscriber by ID. @@ -101,7 +105,7 @@ func handleQuerySubscribers(c echo.Context) error { listIDs := pq.Int64Array{} if listID < 0 { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid `list_id`.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.errorID")) } else if listID > 0 { listIDs = append(listIDs, int64(listID)) } @@ -126,22 +130,24 @@ func handleQuerySubscribers(c echo.Context) error { tx, err := app.db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true}) if err != nil { app.log.Printf("error preparing subscriber query: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error preparing subscriber query: %v", pqErrMsg(err))) + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err))) } defer tx.Rollback() // Run the query. stmt is the raw SQL query. if err := tx.Select(&out.Results, stmt, listIDs, pg.Offset, pg.Limit); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error querying subscribers: %v", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorFetching", + "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) } // Lazy load lists for each subscriber. if err := out.Results.LoadLists(app.queries.GetSubscriberListsLazy); err != nil { app.log.Printf("error fetching subscriber lists: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error fetching subscriber lists: %v", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorFetching", + "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) } out.Query = query @@ -158,6 +164,98 @@ func handleQuerySubscribers(c echo.Context) error { return c.JSON(http.StatusOK, okResp{out}) } +// handleExportSubscribers handles querying subscribers based on an arbitrary SQL expression. +func handleExportSubscribers(c echo.Context) error { + var ( + app = c.Get("app").(*App) + + // Limit the subscribers to a particular list? + listID, _ = strconv.Atoi(c.FormValue("list_id")) + + // The "WHERE ?" bit. + query = sanitizeSQLExp(c.FormValue("query")) + ) + + listIDs := pq.Int64Array{} + if listID < 0 { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.errorID")) + } else if listID > 0 { + listIDs = append(listIDs, int64(listID)) + } + + // There's an arbitrary query condition. + cond := "" + if query != "" { + cond = " AND " + query + } + + stmt := fmt.Sprintf(app.queries.QuerySubscribersForExport, cond) + + // Verify that the arbitrary SQL search expression is read only. + if cond != "" { + tx, err := app.db.Unsafe().BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true}) + if err != nil { + app.log.Printf("error preparing subscriber query: %v", err) + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err))) + } + defer tx.Rollback() + + if _, err := tx.Query(stmt, nil, 0, 1); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err))) + } + } + + // Prepare the actual query statement. + tx, err := db.Preparex(stmt) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err))) + } + + // Run the query until all rows are exhausted. + var ( + id = 0 + + h = c.Response().Header() + wr = csv.NewWriter(c.Response()) + ) + + h.Set(echo.HeaderContentType, echo.MIMEOctetStream) + h.Set("Content-type", "text/csv") + h.Set(echo.HeaderContentDisposition, "attachment; filename="+"subscribers.csv") + h.Set("Content-Transfer-Encoding", "binary") + h.Set("Cache-Control", "no-cache") + wr.Write([]string{"uuid", "email", "name", "attributes", "status", "created_at", "updated_at"}) + +loop: + for { + var out []models.SubscriberExport + if err := tx.Select(&out, listIDs, id, app.constants.DBBatchSize); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts("globals.messages.errorFetching", + "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) + } + if len(out) == 0 { + break loop + } + + 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 { + app.log.Printf("error streaming CSV export: %v", err) + break loop + } + } + wr.Flush() + + id = out[len(out)-1].ID + } + + return nil +} + // handleCreateSubscriber handles the creation of a new subscriber. func handleCreateSubscriber(c echo.Context) error { var ( @@ -177,6 +275,10 @@ func handleCreateSubscriber(c echo.Context) error { // Insert the subscriber into the DB. sub, err := insertSubscriber(req, app) if err != nil { + if err == errSubscriberExists { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.emailExists")) + } + return err } @@ -196,13 +298,13 @@ func handleUpdateSubscriber(c echo.Context) error { } if id < 1 { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } if req.Email != "" && !subimporter.IsEmail(req.Email) { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid `email`.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidEmail")) } if req.Name != "" && !strHasLen(req.Name, 1, stdInputMaxLen) { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid length for `name`.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidName")) } _, err := app.queries.UpdateSubscriber.Exec(req.ID, @@ -214,7 +316,8 @@ func handleUpdateSubscriber(c echo.Context) error { if err != nil { app.log.Printf("error updating subscriber: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error updating subscriber: %v", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorUpdating", + "name", "{globals.terms.subscriber}", "error", pqErrMsg(err))) } // Send a confirmation e-mail (if there are any double opt-in lists). @@ -236,7 +339,7 @@ func handleSubscriberSendOptin(c echo.Context) error { ) if id < 1 { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid subscriber ID.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } // Fetch the subscriber. @@ -244,15 +347,17 @@ func handleSubscriberSendOptin(c echo.Context) error { if err != nil { app.log.Printf("error fetching subscriber: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error fetching subscriber: %s", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorFetching", + "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) } if len(out) == 0 { - return echo.NewHTTPError(http.StatusBadRequest, "Subscriber not found.") + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.subscriber}")) } if err := sendOptinConfirmation(out[0], nil, app); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, - "Error sending opt-in e-mail.") + return echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.T("subscribers.errorSendingOptin")) } return c.JSON(http.StatusOK, okResp{true}) @@ -271,7 +376,7 @@ func handleBlocklistSubscribers(c echo.Context) error { if pID != "" { id, _ := strconv.ParseInt(pID, 10, 64) if id < 1 { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } IDs = append(IDs, id) } else { @@ -279,7 +384,7 @@ func handleBlocklistSubscribers(c echo.Context) error { var req subQueryReq if err := c.Bind(&req); err != nil { return echo.NewHTTPError(http.StatusBadRequest, - fmt.Sprintf("One or more invalid IDs given: %v", err)) + app.i18n.Ts("subscribers.errorInvalidIDs", "error", err.Error())) } if len(req.SubscriberIDs) == 0 { return echo.NewHTTPError(http.StatusBadRequest, @@ -291,7 +396,7 @@ func handleBlocklistSubscribers(c echo.Context) error { if _, err := app.queries.BlocklistSubscribers.Exec(IDs); err != nil { app.log.Printf("error blocklisting subscribers: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error blocklisting: %v", err)) + app.i18n.Ts("subscribers.errorBlocklisting", "error", err.Error())) } return c.JSON(http.StatusOK, okResp{true}) @@ -311,7 +416,7 @@ func handleManageSubscriberLists(c echo.Context) error { if pID != "" { id, _ := strconv.ParseInt(pID, 10, 64) if id < 1 { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } IDs = append(IDs, id) } @@ -319,17 +424,16 @@ func handleManageSubscriberLists(c echo.Context) error { var req subQueryReq if err := c.Bind(&req); err != nil { return echo.NewHTTPError(http.StatusBadRequest, - fmt.Sprintf("One or more invalid IDs given: %v", err)) + app.i18n.Ts("subscribers.errorInvalidIDs", "error", err.Error())) } if len(req.SubscriberIDs) == 0 { - return echo.NewHTTPError(http.StatusBadRequest, - "No IDs given.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.errorNoIDs")) } if len(IDs) == 0 { IDs = req.SubscriberIDs } if len(req.TargetListIDs) == 0 { - return echo.NewHTTPError(http.StatusBadRequest, "No lists given.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.errorNoListsGiven")) } // Action. @@ -342,13 +446,14 @@ func handleManageSubscriberLists(c echo.Context) error { case "unsubscribe": _, err = app.queries.UnsubscribeSubscribersFromLists.Exec(IDs, req.TargetListIDs) default: - return echo.NewHTTPError(http.StatusBadRequest, "Invalid action.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidAction")) } if err != nil { app.log.Printf("error updating subscriptions: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error processing lists: %v", err)) + app.i18n.Ts("globals.messages.errorUpdating", + "name", "{globals.terms.subscribers}", "error", err.Error())) } return c.JSON(http.StatusOK, okResp{true}) @@ -367,7 +472,7 @@ func handleDeleteSubscribers(c echo.Context) error { if pID != "" { id, _ := strconv.ParseInt(pID, 10, 64) if id < 1 { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } IDs = append(IDs, id) } else { @@ -375,11 +480,11 @@ func handleDeleteSubscribers(c echo.Context) error { i, err := parseStringIDs(c.Request().URL.Query()["id"]) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, - fmt.Sprintf("One or more invalid IDs given: %v", err)) + app.i18n.Ts("subscribers.errorInvalidIDs", "error", err.Error())) } if len(i) == 0 { return echo.NewHTTPError(http.StatusBadRequest, - "No IDs given.") + app.i18n.Ts("subscribers.errorNoIDs", "error", err.Error())) } IDs = i } @@ -387,7 +492,8 @@ func handleDeleteSubscribers(c echo.Context) error { if _, err := app.queries.DeleteSubscribers.Exec(IDs, nil); err != nil { app.log.Printf("error deleting subscribers: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error deleting subscribers: %v", err)) + app.i18n.Ts("globals.messages.errorDeleting", + "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) } return c.JSON(http.StatusOK, okResp{true}) @@ -409,9 +515,10 @@ func handleDeleteSubscribersByQuery(c echo.Context) error { app.queries.DeleteSubscribersByQuery, req.ListIDs, app.db) if err != nil { - app.log.Printf("error querying subscribers: %v", err) - return echo.NewHTTPError(http.StatusBadRequest, - fmt.Sprintf("Error: %v", err)) + app.log.Printf("error deleting subscribers: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts("globals.messages.errorDeleting", + "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) } return c.JSON(http.StatusOK, okResp{true}) @@ -434,8 +541,8 @@ func handleBlocklistSubscribersByQuery(c echo.Context) error { req.ListIDs, app.db) if err != nil { app.log.Printf("error blocklisting subscribers: %v", err) - return echo.NewHTTPError(http.StatusBadRequest, - fmt.Sprintf("Error: %v", err)) + return echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts("subscribers.errorBlocklisting", "error", pqErrMsg(err))) } return c.JSON(http.StatusOK, okResp{true}) @@ -453,7 +560,8 @@ func handleManageSubscriberListsByQuery(c echo.Context) error { return err } if len(req.TargetListIDs) == 0 { - return echo.NewHTTPError(http.StatusBadRequest, "No lists given.") + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.T("subscribers.errorNoListsGiven")) } // Action. @@ -466,15 +574,16 @@ func handleManageSubscriberListsByQuery(c echo.Context) error { case "unsubscribe": stmt = app.queries.UnsubscribeSubscribersFromListsByQuery default: - return echo.NewHTTPError(http.StatusBadRequest, "Invalid action.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidAction")) } err := app.queries.execSubscriberQueryTpl(sanitizeSQLExp(req.Query), stmt, req.ListIDs, app.db, req.TargetListIDs) if err != nil { app.log.Printf("error updating subscriptions: %v", err) - return echo.NewHTTPError(http.StatusBadRequest, - fmt.Sprintf("Error: %v", err)) + return echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts("globals.messages.errorUpdating", + "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) } return c.JSON(http.StatusOK, okResp{true}) @@ -491,7 +600,7 @@ func handleExportSubscriberData(c echo.Context) error { ) id, _ := strconv.ParseInt(pID, 10, 64) if id < 1 { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } // Get the subscriber's data. A single query that gets the profile, @@ -500,8 +609,9 @@ func handleExportSubscriberData(c echo.Context) error { _, b, err := exportSubscriberData(id, "", app.constants.Privacy.Exportable, app) if err != nil { app.log.Printf("error exporting subscriber data: %s", err) - return echo.NewHTTPError(http.StatusBadRequest, - "Error exporting subscriber data.") + return echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts("globals.messages.errorFetching", + "name", "{globals.terms.subscribers}", "error", err.Error())) } c.Response().Header().Set("Cache-Control", "no-cache") @@ -527,12 +637,13 @@ func insertSubscriber(req subimporter.SubReq, app *App) (models.Subscriber, erro req.ListUUIDs) if err != nil { if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "subscribers_email_key" { - return req.Subscriber, echo.NewHTTPError(http.StatusBadRequest, "The e-mail already exists.") + return req.Subscriber, errSubscriberExists } app.log.Printf("error inserting subscriber: %v", err) return req.Subscriber, echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error inserting subscriber: %v", err)) + app.i18n.Ts("globals.messages.errorCreating", + "name", "{globals.terms.subscriber}", "error", pqErrMsg(err))) } // Fetch the subscriber's full data. @@ -553,21 +664,25 @@ func getSubscriber(id int, app *App) (models.Subscriber, error) { ) if id < 1 { - return models.Subscriber{}, echo.NewHTTPError(http.StatusBadRequest, "Invalid subscriber ID.") + return models.Subscriber{}, + echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } if err := app.queries.GetSubscriber.Select(&out, id, nil); err != nil { app.log.Printf("error fetching subscriber: %v", err) return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error fetching subscriber: %s", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorFetching", + "name", "{globals.terms.subscriber}", "error", pqErrMsg(err))) } if len(out) == 0 { - return models.Subscriber{}, echo.NewHTTPError(http.StatusBadRequest, "Subscriber not found.") + return models.Subscriber{}, echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.subscriber}")) } if err := out.LoadLists(app.queries.GetSubscriberListsLazy); err != nil { app.log.Printf("error loading subscriber lists: %v", err) return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError, - "Error loading subscriber lists.") + app.i18n.Ts("globals.messages.errorFetching", + "name", "{globals.terms.lists}", "error", pqErrMsg(err))) } return out[0], nil @@ -647,8 +762,8 @@ func sendOptinConfirmation(sub models.Subscriber, listIDs []int64, app *App) err // Send the e-mail. if err := app.sendNotification([]string{sub.Email}, - "Confirm subscription", notifSubscriberOptin, out); err != nil { - app.log.Printf("error e-mailing subscriber profile: %s", err) + app.i18n.T("subscribers.optinSubject"), notifSubscriberOptin, out); err != nil { + app.log.Printf("error sending opt-in e-mail: %s", err) return err } return nil diff --git a/cmd/templates.go b/cmd/templates.go index b31aa27..6977b9a 100644 --- a/cmd/templates.go +++ b/cmd/templates.go @@ -50,16 +50,17 @@ func handleGetTemplates(c echo.Context) error { err := app.queries.GetTemplates.Select(&out, id, noBody) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error fetching templates: %s", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorFetching", + "name", "{globals.terms.templates}", "error", pqErrMsg(err))) } if single && len(out) == 0 { - return echo.NewHTTPError(http.StatusBadRequest, "Template not found.") + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.template}")) } if len(out) == 0 { return c.JSON(http.StatusOK, okResp{[]struct{}{}}) - } - if single { + } else if single { return c.JSON(http.StatusOK, okResp{out[0]}) } @@ -79,21 +80,23 @@ func handlePreviewTemplate(c echo.Context) error { if body != "" { if !regexpTplTag.MatchString(body) { return echo.NewHTTPError(http.StatusBadRequest, - fmt.Sprintf("Template body should contain the %s placeholder exactly once", tplTag)) + app.i18n.Ts("templates.placeholderHelp", "placeholder", tplTag)) } } else { if id < 1 { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } err := app.queries.GetTemplates.Select(&tpls, id, false) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error fetching templates: %s", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorFetching", + "name", "{globals.terms.templates}", "error", pqErrMsg(err))) } if len(tpls) == 0 { - return echo.NewHTTPError(http.StatusBadRequest, "Template not found.") + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.template}")) } body = tpls[0].Body } @@ -101,22 +104,23 @@ func handlePreviewTemplate(c echo.Context) error { // Compile the template. camp := models.Campaign{ UUID: dummyUUID, - Name: "Dummy Campaign", - Subject: "Dummy Campaign Subject", + Name: app.i18n.T("templates.dummyName"), + Subject: app.i18n.T("templates.dummySubject"), FromEmail: "dummy-campaign@listmonk.app", TemplateBody: body, Body: dummyTpl, } if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Error compiling template: %v", err)) + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("templates.errorCompiling", "error", err.Error())) } // Render the message body. m := app.manager.NewCampaignMessage(&camp, dummySubscriber) if err := m.Render(); err != nil { return echo.NewHTTPError(http.StatusBadRequest, - fmt.Sprintf("Error rendering message: %v", err)) + app.i18n.Ts("templates.errorRendering", "error", err.Error())) } return c.HTML(http.StatusOK, string(m.Body())) @@ -133,7 +137,7 @@ func handleCreateTemplate(c echo.Context) error { return err } - if err := validateTemplate(o); err != nil { + if err := validateTemplate(o, app); err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } @@ -143,7 +147,8 @@ func handleCreateTemplate(c echo.Context) error { o.Name, o.Body); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error template user: %v", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorCreating", + "name", "{globals.terms.template}", "error", pqErrMsg(err))) } // Hand over to the GET handler to return the last insertion. @@ -160,7 +165,7 @@ func handleUpdateTemplate(c echo.Context) error { ) if id < 1 { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } var o models.Template @@ -168,7 +173,7 @@ func handleUpdateTemplate(c echo.Context) error { return err } - if err := validateTemplate(o); err != nil { + if err := validateTemplate(o, app); err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } @@ -176,11 +181,13 @@ func handleUpdateTemplate(c echo.Context) error { res, err := app.queries.UpdateTemplate.Exec(o.ID, o.Name, o.Body) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error updating template: %s", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorUpdating", + "name", "{globals.terms.template}", "error", pqErrMsg(err))) } if n, _ := res.RowsAffected(); n == 0 { - return echo.NewHTTPError(http.StatusBadRequest, "Template not found.") + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.template}")) } return handleGetTemplates(c) @@ -194,13 +201,14 @@ func handleTemplateSetDefault(c echo.Context) error { ) if id < 1 { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } _, err := app.queries.SetDefaultTemplate.Exec(id) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error updating template: %s", pqErrMsg(err))) + app.i18n.Ts("globals.messages.errorUpdating", + "name", "{globals.terms.template}", "error", pqErrMsg(err))) } return handleGetTemplates(c) @@ -214,9 +222,10 @@ func handleDeleteTemplate(c echo.Context) error { ) if id < 1 { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.") + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } else if id == 1 { - return echo.NewHTTPError(http.StatusBadRequest, "Cannot delete the primordial template.") + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.T("templates.cantDeleteDefault")) } var delID int @@ -226,26 +235,28 @@ func handleDeleteTemplate(c echo.Context) error { return c.JSON(http.StatusOK, okResp{true}) } - return echo.NewHTTPError(http.StatusBadRequest, - fmt.Sprintf("Error deleting template: %v", err)) + return echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts("globals.messages.errorCreating", + "name", "{globals.terms.template}", "error", pqErrMsg(err))) } if delID == 0 { return echo.NewHTTPError(http.StatusBadRequest, - "Cannot delete the last, default, or non-existent template.") + app.i18n.T("templates.cantDeleteDefault")) } return c.JSON(http.StatusOK, okResp{true}) } // validateTemplate validates template fields. -func validateTemplate(o models.Template) error { +func validateTemplate(o models.Template, app *App) error { if !strHasLen(o.Name, 1, stdInputMaxLen) { - return errors.New("invalid length for `name`") + return errors.New(app.i18n.T("campaigns.fieldInvalidName")) } if !regexpTplTag.MatchString(o.Body) { - return fmt.Errorf("template body should contain the %s placeholder exactly once", tplTag) + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("templates.placeholderHelp", "placeholder", tplTag)) } return nil diff --git a/cmd/upgrade.go b/cmd/upgrade.go index 44eb4be..8fb7603 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -28,6 +28,7 @@ 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}, } // upgrade upgrades the database to the current version by running SQL migration files diff --git a/cmd/utils.go b/cmd/utils.go index 2f8c36b..900d097 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -12,9 +12,6 @@ import ( ) var ( - - // This replaces all special characters - tagRegexp = regexp.MustCompile(`[^a-z0-9\-\s]`) tagRegexpSpaces = regexp.MustCompile(`[\s]+`) ) @@ -62,14 +59,12 @@ func pqErrMsg(err error) string { // lowercasing and removing all special characters except for dashes. func normalizeTags(tags []string) []string { var ( - out []string - space = []byte(" ") - dash = []byte("-") + out []string + dash = []byte("-") ) for _, t := range tags { - rep := bytes.TrimSpace(tagRegexp.ReplaceAll(bytes.ToLower([]byte(t)), space)) - rep = tagRegexpSpaces.ReplaceAll(rep, dash) + rep := tagRegexpSpaces.ReplaceAll(bytes.TrimSpace([]byte(t)), dash) if len(rep) > 0 { out = append(out, string(rep)) diff --git a/frontend/package.json b/frontend/package.json index 1a4f980..6b921dc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,8 +24,10 @@ "quill": "^1.3.7", "quill-delta": "^4.2.2", "sass-loader": "^8.0.2", + "textversionjs": "^1.1.3", "vue": "^2.6.11", "vue-c3": "^1.2.11", + "vue-i18n": "^8.22.2", "vue-quill-editor": "^3.0.6", "vue-router": "^3.2.0", "vuex": "^3.4.0" diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 7b5c9aa..49bcb47 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -28,68 +28,68 @@ + icon="view-dashboard-variant-outline" :label="$t('menu.dashboard')"> + icon="format-list-bulleted-square" :label="$t('globals.terms.lists')"> + icon="format-list-bulleted-square" :label="$t('menu.allLists')"> + icon="newspaper-variant-outline" :label="$t('menu.forms')"> + icon="account-multiple" :label="$t('globals.terms.subscribers')"> + icon="account-multiple" :label="$t('menu.allSubscribers')"> + icon="file-upload-outline" :label="$t('menu.import')"> + icon="rocket-launch-outline" :label="$t('globals.terms.campaigns')"> + icon="rocket-launch-outline" :label="$t('menu.allCampaigns')"> + icon="plus" :label="$t('menu.newCampaign')"> + icon="image-outline" :label="$t('menu.media')"> + icon="file-image-outline" :label="$t('globals.terms.templates')"> + icon="cog-outline" :label="$t('menu.settings')"> + icon="cog-outline" :label="$t('menu.settings')"> + icon="newspaper-variant-outline" :label="$t('menu.logs')"> diff --git a/frontend/src/assets/style.scss b/frontend/src/assets/style.scss index 714f699..25ed8fb 100644 --- a/frontend/src/assets/style.scss +++ b/frontend/src/assets/style.scss @@ -224,6 +224,16 @@ section { } } +.editor { + margin-bottom: 30px; +} + .plain-editor textarea { + height: 65vh; + } + .alt-body textarea { + height: 30vh; + } + /* Table colors and padding */ .main table { thead th { diff --git a/frontend/src/components/Editor.vue b/frontend/src/components/Editor.vue index 809dacd..6ec4646 100644 --- a/frontend/src/components/Editor.vue +++ b/frontend/src/components/Editor.vue @@ -7,19 +7,19 @@
Rich text + native-value="richtext">{{ $t('campaigns.richText') }} Raw HTML + native-value="html">{{ $t('campaigns.rawHTML') }} Plain text + native-value="plain">{{ $t('campaigns.plainText') }}
Preview + icon-left="file-find-outline">{{ $t('campaigns.preview') }}
@@ -31,7 +31,7 @@ ref="quill" :options="options" :disabled="disabled" - placeholder="Content here" + :placeholder="$t('campaigns.contentHelp')" @change="onEditorChange($event)" @ready="onEditorReady($event)" /> @@ -142,7 +142,7 @@ export default { // Quill editor options. options: { - placeholder: 'Content here', + placeholder: this.$t('campaigns.contentHelp'), modules: { keyboard: { bindings: { @@ -188,7 +188,7 @@ export default { methods: { onChangeFormat(format) { this.$utils.confirm( - 'The content may lose some formatting. Are you sure?', + this.$t('campaigns.confirmSwitchFormat'), () => { this.form.format = format; this.onEditorChange(); @@ -244,6 +244,8 @@ export default { this.form.body = b; this.$emit('input', { contentType: this.form.format, body: this.form.body }); }); + + this.isReady = true; }, onTogglePreview() { @@ -288,6 +290,7 @@ export default { body(b) { this.form.body = b; + this.onEditorChange(); }, htmlFormat(f) { diff --git a/frontend/src/components/EmptyPlaceholder.vue b/frontend/src/components/EmptyPlaceholder.vue index 0500e11..75d651a 100644 --- a/frontend/src/components/EmptyPlaceholder.vue +++ b/frontend/src/components/EmptyPlaceholder.vue @@ -4,7 +4,7 @@

-

{{ !label ? 'Nothing here' : label }}

+

{{ !label ? $t('globals.messages.emptyState') : label }}

diff --git a/frontend/src/constants.js b/frontend/src/constants.js index ffed0bb..0654ac1 100644 --- a/frontend/src/constants.js +++ b/frontend/src/constants.js @@ -18,6 +18,7 @@ export const uris = Object.freeze({ previewCampaign: '/api/campaigns/:id/preview', previewTemplate: '/api/templates/:id/preview', previewRawTemplate: '/api/templates/preview', + exportSubscribers: '/api/subscribers/export', }); // Keys used in Vuex store. diff --git a/frontend/src/main.js b/frontend/src/main.js index 99cd0c9..c628886 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -1,45 +1,63 @@ import Vue from 'vue'; import Buefy from 'buefy'; import humps from 'humps'; +import VueI18n from 'vue-i18n'; import App from './App.vue'; import router from './router'; import store from './store'; import * as api from './api'; -import utils from './utils'; import { models } from './constants'; +import Utils from './utils'; + +// Internationalisation. +Vue.use(VueI18n); +const i18n = new VueI18n(); Vue.use(Buefy, {}); Vue.config.productionTip = false; -// Custom global elements. -Vue.prototype.$api = api; -Vue.prototype.$utils = utils; +// Globals. +const ut = new Utils(i18n); +Vue.mixin({ + computed: { + $utils: () => ut, + $api: () => api, + }, -Vue.prototype.$reloadServerConfig = () => { - // Get the config.js