diff --git a/Makefile b/Makefile index 9ef68aa..f2d4718 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 diff --git a/cmd/admin.go b/cmd/admin.go index e2e80af..9bdf574 100644 --- a/cmd/admin.go +++ b/cmd/admin.go @@ -14,12 +14,14 @@ 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"` + Lang json.RawMessage `json:"lang"` } // handleGetConfigScript returns general configuration as a Javascript @@ -34,6 +36,17 @@ func handleGetConfigScript(c echo.Context) error { } ) + // 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 +64,19 @@ 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", map[string]string{ + "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,10 @@ 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", map[string]string{ + "name": "dashboard charts", + "error": pqErrMsg(err), + })) } return c.JSON(http.StatusOK, okResp{out}) @@ -84,7 +106,10 @@ 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", map[string]string{ + "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..dbf312a 100644 --- a/cmd/campaigns.go +++ b/cmd/campaigns.go @@ -106,10 +106,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.Ts2("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.Ts2("campaigns.notFound", "name", "{globals.terms.campaign}")) } if len(out.Results) == 0 { out.Results = []models.Campaign{} @@ -131,7 +133,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.Ts2("globals.messages.errorFetching", + "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } if single { @@ -157,18 +160,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.Ts2("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.Ts2("globals.messages.errorFetching", + "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } var sub models.Subscriber @@ -180,7 +185,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.Ts2("globals.messages.errorFetching", + "name", "{globals.terms.subscriber}", "error", pqErrMsg(err))) } } @@ -192,7 +198,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.Ts2("templates.errorCompiling", "error", err.Error())) } // Render the message body. @@ -200,7 +206,7 @@ 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.Ts2("templates.errorRendering", "error", err.Error())) } return c.HTML(http.StatusOK, string(m.Body())) @@ -237,7 +243,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.Ts2("globals.messages.errorUUID", "error", err.Error())) } // Insert and read ID. @@ -257,13 +264,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.Ts2("globals.messages.errorCreating", + "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } // Hand over to the GET handler to return the last insertion. @@ -281,23 +288,25 @@ 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.Ts2("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.Ts2("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. @@ -327,7 +336,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.Ts2("globals.messages.errorUpdating", + "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } return handleGetCampaigns(c) @@ -341,18 +351,23 @@ 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("", map[string]string{ + "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.Ts2("globals.messages.errorFetching", + "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } // Incoming params. @@ -365,27 +380,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 +411,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.Ts2("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.Ts2("globals.messages.notFound", + "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } return handleGetCampaigns(c) @@ -416,24 +435,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.Ts2("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.Ts2("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.Ts2("globals.messages.errorDeleting", + "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) + } return c.JSON(http.StatusOK, okResp{true}) @@ -453,7 +477,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.Ts2("globals.messages.errorFetching", + "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } else if len(out) == 0 { return c.JSON(http.StatusOK, okResp{[]struct{}{}}) } @@ -488,7 +513,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 +528,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,21 +539,25 @@ 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.Ts2("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.Ts2("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.Ts2("globals.messages.errorFetching", + "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } // Override certain values in the DB with incoming values. @@ -544,8 +573,8 @@ 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)) + return echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts2("campaigns.errorSendTest", "error", err.Error())) } } @@ -556,15 +585,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.Ts2("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.Ts2("templates.errorRendering", "error", err.Error())) } return app.messengers[camp.Messenger].Push(messenger.Message{ @@ -584,15 +614,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 +632,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.Ts2("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.Ts2("campaigns.fieldInvalidBody", "error", err.Error())) } return c, nil @@ -633,7 +663,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 +672,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.Ts2("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 +696,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.Ts2("templates.errorCompiling", "error", err.Error())) } o.Body = b.String() diff --git a/cmd/handlers.go b/cmd/handlers.go index b699854..295a321 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) @@ -154,6 +160,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 +197,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 +223,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..a107c20 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.Ts2("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.Ts2("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.Ts2("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.Ts2("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.Ts2("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.Ts2("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..3a18cb7 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" @@ -44,6 +45,7 @@ type constants struct { FaviconURL string `koanf:"favicon_url"` FromEmail string `koanf:"from_email"` NotifyEmails []string `koanf:"notify_emails"` + Lang string `koanf:"lang"` Privacy struct { IndividualTracking bool `koanf:"individual_tracking"` AllowBlocklist bool `koanf:"allow_blocklist"` @@ -131,6 +133,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 +233,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 +255,22 @@ func initConstants() *constants { return &c } +// initI18n initializes a new i18n instance with the selected language map +// loaded from the filesystem. +func initI18n(lang string, fs stuffbin.FileSystem) *i18n.I18nLang { + b, err := fs.Read(fmt.Sprintf("/i18n/%s.json", lang)) + if err != nil { + lo.Fatalf("error loading i18n language file: %v", err) + } + + i, err := i18n.New(lang, b) + if err != nil { + lo.Fatalf("error unmarshalling i18n language: %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 { @@ -407,7 +427,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.I18nLang, cs *constants) *template.Template { // Register utility functions that the e-mail templates can use. funcs := template.FuncMap{ "RootURL": func() string { @@ -415,7 +435,11 @@ func initNotifTemplates(path string, fs stuffbin.FileSystem, cs *constants) *tem }, "LogoURL": func() string { return cs.LogoURL - }} + }, + "L": func() *i18n.I18nLang { + return i + }, + } tpl, err := stuffbin.ParseTemplatesGlob(funcs, fs, "/static/email-templates/*.html") if err != nil { diff --git a/cmd/lists.go b/cmd/lists.go index 9c3c20a..53cac81 100644 --- a/cmd/lists.go +++ b/cmd/lists.go @@ -53,10 +53,12 @@ func handleGetLists(c echo.Context) error { if err := db.Select(&out.Results, fmt.Sprintf(app.queries.GetLists, 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.Ts2("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.Ts2("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", map[string]string{"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.Ts2("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.Ts2("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.Ts2("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.Ts2("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..afb1ae3 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.I18nLang 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..db10e6b 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.Ts2("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.Ts2("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.Ts2("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.Ts2("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.Ts2("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.Ts2("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.Ts2("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.Ts2("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.Ts2("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.Ts2("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..72b489a 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.I18nLang } type publicTpl struct { @@ -82,6 +84,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 +102,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.Ts2("public.errorFetchingCampaign"))) } // Get the subscriber. @@ -112,19 +117,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.Ts2("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.Ts2("public.errorFetchingCampaign"))) } // Render the message body. @@ -132,7 +140,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.Ts2("public.errorFetchingCampaign"))) } return c.HTML(http.StatusOK, string(m.Body())) @@ -151,7 +160,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 +175,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.Ts2("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 +198,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 +211,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 +221,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.Ts2("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.Ts2("public.noSubInfo"))) } // Confirm. @@ -228,12 +239,13 @@ 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.Ts2("public.errorProcessingRequest"))) } + return c.Render(http.StatusOK, tplMessage, - makeMsgTpl("Confirmed", "", - `Your subscriptions have been confirmed.`)) + makeMsgTpl(app.i18n.T("public.subsConfirmedTitle"), "", + app.i18n.Ts2("public.subConfirmed"))) } return c.Render(http.StatusOK, "optin", out) @@ -253,9 +265,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("globals.messages.invalidUUID"))) } // If there's no name, use the name bit from the e-mail. @@ -267,7 +279,7 @@ 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. @@ -275,11 +287,12 @@ func handleSubscriptionForm(c echo.Context) error { req.ListUUIDs = pq.StringArray(req.SubListUUIDs) if _, err := insertSubscriber(req.SubReq, app); err != nil { 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.subsConfirmedTitle"), "", + app.i18n.Ts2("public.subConfirmed"))) } // handleLinkRedirect redirects a link UUID to its original underlying link @@ -302,12 +315,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.Ts2("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.Ts2("public.errorProcessingRequest"))) } return c.Redirect(http.StatusTemporaryRedirect, url) @@ -352,7 +367,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.Ts2("public.invalidFeature"))) } // Get the subscriber's data. A single query that gets the profile, @@ -362,18 +378,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.Ts2("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.Ts2("public.errorProcessingRequest"))) } // Send the data as a JSON attachment to the subscriber. @@ -393,12 +408,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.Ts2("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 +429,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.Ts2("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.Ts2("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/settings.go b/cmd/settings.go index ae416dc..3ec84a7 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,17 @@ 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"` + 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"` PrivacyIndividualTracking bool `json:"privacy.individual_tracking"` PrivacyUnsubHeader bool `json:"privacy.unsubscribe_header"` @@ -144,8 +145,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 +169,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.Ts2("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 +188,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.Ts2("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.Ts2("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 +233,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.Ts2("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.Ts2("settings.errorEncoding", "error", err.Error())) } return out, nil diff --git a/cmd/subscribers.go b/cmd/subscribers.go index dbe461c..e0c0cf9 100644 --- a/cmd/subscribers.go +++ b/cmd/subscribers.go @@ -101,7 +101,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 +126,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.Ts2("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.Ts2("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.Ts2("globals.messages.errorFetching", + "name", "globals.terms.subscribers", "error", pqErrMsg(err))) } out.Query = query @@ -196,13 +198,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 +216,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.Ts2("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 +239,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 +247,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.Ts2("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.Ts2("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 +276,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 +284,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.Ts2("subscribers.errorInvalidIDs", "error", err.Error())) } if len(req.SubscriberIDs) == 0 { return echo.NewHTTPError(http.StatusBadRequest, @@ -291,7 +296,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.Ts2("subscribers.errorBlocklisting", "error", err.Error())) } return c.JSON(http.StatusOK, okResp{true}) @@ -311,7 +316,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 +324,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.Ts2("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 +346,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.Ts2("globals.messages.errorUpdating", + "name", "{globals.terms.subscribers}", "error", err.Error())) } return c.JSON(http.StatusOK, okResp{true}) @@ -367,7 +372,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 +380,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.Ts2("subscribers.errorInvalidIDs", "error", err.Error())) } if len(i) == 0 { return echo.NewHTTPError(http.StatusBadRequest, - "No IDs given.") + app.i18n.Ts2("subscribers.errorNoIDs", "error", err.Error())) } IDs = i } @@ -387,7 +392,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.Ts2("globals.messages.errorDeleting", + "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) } return c.JSON(http.StatusOK, okResp{true}) @@ -409,9 +415,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.Ts2("globals.messages.errorDeleting", + "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) } return c.JSON(http.StatusOK, okResp{true}) @@ -434,8 +441,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.Ts2("subscribers.errorBlocklisting", "error", pqErrMsg(err))) } return c.JSON(http.StatusOK, okResp{true}) @@ -453,7 +460,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 +474,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.Ts2("globals.messages.errorUpdating", + "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) } return c.JSON(http.StatusOK, okResp{true}) @@ -491,7 +500,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 +509,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.Ts2("globals.messages.errorFetching", + "name", "globals.terms.subscribers", "error", err.Error())) } c.Response().Header().Set("Cache-Control", "no-cache") @@ -527,12 +537,14 @@ 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, + echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.emailExists")) } app.log.Printf("error inserting subscriber: %v", err) return req.Subscriber, echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("Error inserting subscriber: %v", err)) + app.i18n.Ts2("globals.messages.errorCreating", + "name", "{globals.terms.subscriber}", "error", pqErrMsg(err))) } // Fetch the subscriber's full data. @@ -553,21 +565,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.Ts2("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.Ts2("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.Ts2("globals.messages.errorFetching", + "name", "globals.terms.lists", "error", pqErrMsg(err))) } return out[0], nil @@ -647,8 +663,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..4568c38 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.Ts2("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.Ts2("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.Ts2("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.Ts2("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.Ts2("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.Ts2("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.Ts2("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.Ts2("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.Ts2("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.Ts2("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.Ts2("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.Ts2("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.Ts2("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/frontend/package.json b/frontend/package.json index 1a4f980..5f0c3dc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,7 @@ "sass-loader": "^8.0.2", "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..4c5ed9e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -28,16 +28,16 @@ + 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="account-multiple" :label="$t('globals.terms.subscribers')"> + icon="account-multiple" :label="$t('menu.allSubscribers')"> + 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/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/main.js b/frontend/src/main.js index 99cd0c9..2f30736 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -1,6 +1,7 @@ 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'; @@ -9,6 +10,12 @@ import * as api from './api'; import utils from './utils'; import { models } from './constants'; +// Internationalisation. +Vue.use(VueI18n); + +// Create VueI18n instance with options +const i18n = new VueI18n(); + Vue.use(Buefy, {}); Vue.config.productionTip = false; @@ -36,10 +43,15 @@ Vue.prototype.$reloadServerConfig = () => { if (window.CONFIG) { store.commit('setModelResponse', { model: models.serverConfig, data: humps.camelizeKeys(window.CONFIG) }); + + // Load language. + i18n.locale = window.CONFIG.lang['_.code']; + i18n.setLocaleMessage(i18n.locale, window.CONFIG.lang); } new Vue({ router, store, + i18n, render: (h) => h(App), }).$mount('#app'); diff --git a/frontend/src/views/Campaign.vue b/frontend/src/views/Campaign.vue index 279ff20..60cd976 100644 --- a/frontend/src/views/Campaign.vue +++ b/frontend/src/views/Campaign.vue @@ -6,25 +6,27 @@ {{ data.status }} {{ data.type }} - ID: {{ data.id }} / UUID: {{ data.uuid }} + {{ $t('globals.fields.id') }}: {{ data.id }} / + {{ $t('globals.fields.uuid') }}: {{ data.uuid }}

{{ data.name }}

-

New campaign

+

{{ $t('campaigns.newCampaign') }}

Save changes - + type="is-primary" icon-left="content-save-outline"> + {{ $t('globals.buttons.saveChanges') }} + - Start campaign + {{ $t('campaigns.start') }} - Schedule campaign + {{ $t('campaigns.schedule') }}
@@ -33,24 +35,25 @@ - +
- + + placeholder="$t('globals.fields.name')" required> - + + :placeholder="$t('campaigns.subject')" required> - + + :placeholder="$t('campaigns.fromAddressPlaceholder')" required> - - + - - + - + + ellipsis icon="tag-outline" :placeholder="$t('globals.terms.tags')" />
- - + +
@@ -96,7 +99,7 @@ Continue + :loading="loading.campaigns">{{ $t('campaigns.continue') }}

-

Send test message

- - {{ $t('campaigns.sendTest') }} + + + ellipsis icon="email-outline" :placeholder="$t('campaigns.testEmails')" /> Send + type="is-primary" icon-left="email-outline"> + {{ $t('campaigns.send') }} +
@@ -233,7 +237,7 @@ export default Vue.extend({ }; this.$api.testCampaign(data).then(() => { - this.$utils.toast('Test message sent'); + this.$utils.toast(this.$t('campaigns.testSent')); }); return false; }, @@ -282,16 +286,16 @@ export default Vue.extend({ body: this.form.content.body, }; - let typMsg = 'updated'; + let typMsg = 'globals.messages.updated'; if (typ === 'start') { - typMsg = 'started'; + typMsg = 'campaigns.started'; } // This promise is used by startCampaign to first save before starting. return new Promise((resolve) => { this.$api.updateCampaign(this.data.id, data).then((d) => { this.data = d; - this.$utils.toast(`'${d.name}' ${typMsg}`); + this.$utils.toast(this.$t(typMsg, { name: d.name })); resolve(); }); }); @@ -373,7 +377,7 @@ export default Vue.extend({ } else { const intID = parseInt(id, 10); if (intID <= 0 || Number.isNaN(intID)) { - this.$utils.toast('Invalid campaign'); + this.$utils.toast(this.$t('campaigns.invalid')); return; } diff --git a/frontend/src/views/Campaigns.vue b/frontend/src/views/Campaigns.vue index cfb7888..bf06a92 100644 --- a/frontend/src/views/Campaigns.vue +++ b/frontend/src/views/Campaigns.vue @@ -2,20 +2,20 @@
-

Campaigns +

{{ $t('globals.terms.campaigns') }} ({{ campaigns.total }})

New + type="is-primary" icon-left="plus">{{ $t('globals.buttons.new') }}
+ :placeholder="$t('campaigns.queryPlaceholder')" icon="magnify" ref="query">
@@ -40,7 +40,7 @@

- + {{ $utils.duration(Date(), props.row.sendAt, true) }} @@ -50,7 +50,7 @@

- +

@@ -65,7 +65,8 @@

- +
  • @@ -74,7 +75,8 @@
- +

@@ -99,15 +101,15 @@

- + {{ props.row.views }}

- + {{ props.row.clicks }}

- + {{ stats.sent }} / {{ stats.toSend }}

@@ -117,7 +119,7 @@

-

{{ $utils.niceNumber(counts.lists.total) }}

-

Lists

+

+ {{ $tc('globals.terms.list', counts.lists.total) }} +

  • - public + + {{ $t('lists.types.public') }}
  • - private + + {{ $t('lists.types.private') }}
  • - single opt-in + {{ $t('lists.optins.single') }}
  • - double opt-in
  • + {{ $t('lists.optins.double') }} +
@@ -42,7 +47,9 @@

{{ $utils.niceNumber(counts.campaigns.total) }}

-

Campaigns

+

+ {{ $tc('globals.terms.campaign', counts.campaigns.total) }} +

    @@ -61,18 +68,20 @@

    {{ $utils.niceNumber(counts.subscribers.total) }}

    -

    Subscribers

    +

    + {{ $tc('globals.terms.subscriber', counts.subscribers.total) }} +

    • - blocklisted + {{ $t('subscribers.status.blocklisted') }}
    • - orphans + {{ $t('dashboard.orphanSubs') }}
    @@ -81,7 +90,9 @@

    {{ $utils.niceNumber(counts.messages) }}

    -

    Messages sent

    +

    + {{ $t('dashboard.messagesSent') }} +

    @@ -92,12 +103,14 @@
    -

    Campaign views


    +

    {{ $t('dashboard.campaignViews') }}


    -

    Link clicks


    +

    + {{ $t('dashboard.linkClicks') }} +


    @@ -200,7 +213,7 @@ export default Vue.extend({ this.$nextTick(() => { this.chartViewsInst.$emit('init', - this.makeChart('Campaign views', data.campaignViews)); + this.makeChart(this.$t('dashboard.campaignViews'), data.campaignViews)); }); } @@ -209,7 +222,7 @@ export default Vue.extend({ this.$nextTick(() => { this.chartClicksInst.$emit('init', - this.makeChart('Link clicks', data.linkClicks)); + this.makeChart(this.$t('dashboard.linkClicks'), data.linkClicks)); }); } }); diff --git a/frontend/src/views/Forms.vue b/frontend/src/views/Forms.vue index 2a864bd..e943988 100644 --- a/frontend/src/views/Forms.vue +++ b/frontend/src/views/Forms.vue @@ -1,12 +1,12 @@ @@ -66,7 +63,7 @@ export default Vue.extend({ }, computed: { - ...mapState(['lists', 'loading']), + ...mapState(['lists', 'loading', 'serverConfig']), publicLists() { if (!this.lists.results) { diff --git a/frontend/src/views/Import.vue b/frontend/src/views/Import.vue index 554d36f..b9c0d51 100644 --- a/frontend/src/views/Import.vue +++ b/frontend/src/views/Import.vue @@ -1,7 +1,6 @@