WIP: Add i18n support

This commit is contained in:
Kailash Nadh 2020-12-19 16:25:52 +05:30
parent dae47fbeaa
commit 3498a727f5
47 changed files with 1483 additions and 680 deletions

View file

@ -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

View file

@ -20,6 +20,8 @@ type configScript struct {
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})

View file

@ -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()

View file

@ -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)
}

44
cmd/i18n.go Normal file
View file

@ -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
}

View file

@ -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]))
}

View file

@ -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 {

View file

@ -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})

View file

@ -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)

View file

@ -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.

View file

@ -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

View file

@ -2,7 +2,6 @@ package main
import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
@ -20,6 +19,8 @@ type settings struct {
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"`
@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -28,16 +28,16 @@
<b-menu-list>
<b-menu-item :to="{name: 'dashboard'}" tag="router-link"
:active="activeItem.dashboard"
icon="view-dashboard-variant-outline" label="Dashboard">
icon="view-dashboard-variant-outline" :label="$t('menu.dashboard')">
</b-menu-item><!-- dashboard -->
<b-menu-item :expanded="activeGroup.lists"
:active="activeGroup.lists"
v-on:update:active="(state) => toggleGroup('lists', state)"
icon="format-list-bulleted-square" label="Lists">
icon="format-list-bulleted-square" :label="$t('globals.terms.lists')">
<b-menu-item :to="{name: 'lists'}" tag="router-link"
:active="activeItem.lists"
icon="format-list-bulleted-square" label="All lists"></b-menu-item>
icon="format-list-bulleted-square" :label="$t('menu.allLists')"></b-menu-item>
<b-menu-item :to="{name: 'forms'}" tag="router-link"
:active="activeItem.forms"
@ -47,10 +47,10 @@
<b-menu-item :expanded="activeGroup.subscribers"
:active="activeGroup.subscribers"
v-on:update:active="(state) => toggleGroup('subscribers', state)"
icon="account-multiple" label="Subscribers">
icon="account-multiple" :label="$t('globals.terms.subscribers')">
<b-menu-item :to="{name: 'subscribers'}" tag="router-link"
:active="activeItem.subscribers"
icon="account-multiple" label="All subscribers"></b-menu-item>
icon="account-multiple" :label="$t('menu.allSubscribers')"></b-menu-item>
<b-menu-item :to="{name: 'import'}" tag="router-link"
:active="activeItem.import"
@ -60,36 +60,36 @@
<b-menu-item :expanded="activeGroup.campaigns"
:active="activeGroup.campaigns"
v-on:update:active="(state) => toggleGroup('campaigns', state)"
icon="rocket-launch-outline" label="Campaigns">
icon="rocket-launch-outline" :label="$t('globals.terms.campaigns')">
<b-menu-item :to="{name: 'campaigns'}" tag="router-link"
:active="activeItem.campaigns"
icon="rocket-launch-outline" label="All campaigns"></b-menu-item>
icon="rocket-launch-outline" :label="$t('menu.allCampaigns')"></b-menu-item>
<b-menu-item :to="{name: 'campaign', params: {id: 'new'}}" tag="router-link"
:active="activeItem.campaign"
icon="plus" label="Create new"></b-menu-item>
icon="plus" :label="$t('menu.newCampaign')"></b-menu-item>
<b-menu-item :to="{name: 'media'}" tag="router-link"
:active="activeItem.media"
icon="image-outline" label="Media"></b-menu-item>
icon="image-outline" :label="$t('menu.media')"></b-menu-item>
<b-menu-item :to="{name: 'templates'}" tag="router-link"
:active="activeItem.templates"
icon="file-image-outline" label="Templates"></b-menu-item>
icon="file-image-outline" :label="$t('globals.terms.templates')"></b-menu-item>
</b-menu-item><!-- campaigns -->
<b-menu-item :expanded="activeGroup.settings"
:active="activeGroup.settings"
v-on:update:active="(state) => toggleGroup('settings', state)"
icon="cog-outline" label="Settings">
icon="cog-outline" :label="$t('menu.settings')">
<b-menu-item :to="{name: 'settings'}" tag="router-link"
:active="activeItem.settings"
icon="cog-outline" label="Settings"></b-menu-item>
icon="cog-outline" :label="$t('menu.settings')"></b-menu-item>
<b-menu-item :to="{name: 'logs'}" tag="router-link"
:active="activeItem.logs"
icon="newspaper-variant-outline" label="Logs"></b-menu-item>
icon="newspaper-variant-outline" :label="$t('menu.logs')"></b-menu-item>
</b-menu-item><!-- settings -->
</b-menu-list>
</b-menu>

View file

@ -4,7 +4,7 @@
<p>
<b-icon :icon="!icon ? 'plus' : icon" size="is-large" />
</p>
<p>{{ !label ? 'Nothing here' : label }}</p>
<p>{{ !label ? $t('globals.messages.emptyState') : label }}</p>
</div>
</section>
</template>

View file

@ -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');

View file

@ -6,25 +6,27 @@
<b-tag v-if="isEditing" :class="data.status">{{ data.status }}</b-tag>
<b-tag v-if="data.type === 'optin'" :class="data.type">{{ data.type }}</b-tag>
<span v-if="isEditing" class="has-text-grey-light is-size-7">
ID: {{ data.id }} / UUID: {{ data.uuid }}
{{ $t('globals.fields.id') }}: {{ data.id }} /
{{ $t('globals.fields.uuid') }}: {{ data.uuid }}
</span>
</p>
<h4 v-if="isEditing" class="title is-4">{{ data.name }}</h4>
<h4 v-else class="title is-4">New campaign</h4>
<h4 v-else class="title is-4">{{ $t('campaigns.newCampaign') }}</h4>
</div>
<div class="column">
<div class="buttons" v-if="isEditing && canEdit">
<b-button @click="onSubmit" :loading="loading.campaigns"
type="is-primary" icon-left="content-save-outline">Save changes</b-button>
type="is-primary" icon-left="content-save-outline">
{{ $t('globals.buttons.saveChanges') }}
</b-button>
<b-button v-if="canStart" @click="startCampaign" :loading="loading.campaigns"
type="is-primary" icon-left="rocket-launch-outline">
Start campaign
{{ $t('campaigns.start') }}
</b-button>
<b-button v-if="canSchedule" @click="startCampaign" :loading="loading.campaigns"
type="is-primary" icon-left="clock-start">
Schedule campaign
{{ $t('campaigns.schedule') }}
</b-button>
</div>
</div>
@ -33,24 +35,25 @@
<b-loading :active="loading.campaigns"></b-loading>
<b-tabs type="is-boxed" :animated="false" v-model="activeTab">
<b-tab-item label="Campaign" label-position="on-border" icon="rocket-launch-outline">
<b-tab-item :label="$tc('globals.terms.campaign')" label-position="on-border"
icon="rocket-launch-outline">
<section class="wrap">
<div class="columns">
<div class="column is-7">
<form @submit.prevent="onSubmit">
<b-field label="Name" label-position="on-border">
<b-field :label="$t('globals.fields.name')" label-position="on-border">
<b-input :maxlength="200" :ref="'focus'" v-model="form.name" :disabled="!canEdit"
placeholder="Name" required></b-input>
placeholder="$t('globals.fields.name')" required></b-input>
</b-field>
<b-field label="Subject" label-position="on-border">
<b-field :label="$t('campaigns.subject')" label-position="on-border">
<b-input :maxlength="200" v-model="form.subject" :disabled="!canEdit"
placeholder="Subject" required></b-input>
:placeholder="$t('campaigns.subject')" required></b-input>
</b-field>
<b-field label="From address" label-position="on-border">
<b-field :label="$t('campaigns.fromAddress')" label-position="on-border">
<b-input :maxlength="200" v-model="form.fromEmail" :disabled="!canEdit"
placeholder="Your Name <noreply@yoursite.com>" required></b-input>
:placeholder="$t('campaigns.fromAddressPlaceholder')" required></b-input>
</b-field>
<list-selector
@ -58,35 +61,35 @@
:selected="form.lists"
:all="lists.results"
:disabled="!canEdit"
label="Lists"
placeholder="Lists to send to"
:label="$t('globals.terms.lists')"
:placeholder="$t('campaigns.sendToLists')"
></list-selector>
<b-field label="Template" label-position="on-border">
<b-select placeholder="Template" v-model="form.templateId"
<b-field :label="$tc('terms.template')" label-position="on-border">
<b-select :placeholder="$tc('terms.template')" v-model="form.templateId"
:disabled="!canEdit" required>
<option v-for="t in templates" :value="t.id" :key="t.id">{{ t.name }}</option>
</b-select>
</b-field>
<b-field label="Messenger" label-position="on-border">
<b-select placeholder="Messenger" v-model="form.messenger"
<b-field :label="$tc('terms.messenger')" label-position="on-border">
<b-select :placeholder="$tc('terms.messenger')" v-model="form.messenger"
:disabled="!canEdit" required>
<option v-for="m in serverConfig.messengers"
:value="m" :key="m">{{ m }}</option>
</b-select>
</b-field>
<b-field label="Tags" label-position="on-border">
<b-field :label="$t('globals.terms.tags')" label-position="on-border">
<b-taginput v-model="form.tags" :disabled="!canEdit"
ellipsis icon="tag-outline" placeholder="Tags"></b-taginput>
ellipsis icon="tag-outline" :placeholder="$t('globals.terms.tags')" />
</b-field>
<hr />
<div class="columns">
<div class="column is-2">
<b-field label="Send later?">
<b-switch v-model="form.sendLater" :disabled="!canEdit"></b-switch>
<b-field :label="$t('campaigns.sendLater')">
<b-switch v-model="form.sendLater" :disabled="!canEdit" />
</b-field>
</div>
<div class="column">
@ -96,7 +99,7 @@
<b-datetimepicker
v-model="form.sendAtDate"
:disabled="!canEdit"
placeholder="Date and time"
:placeholder="$t('dateAndTime')"
icon="calendar-clock"
:timepicker="{ hourFormat: '24' }"
:datetime-formatter="formatDateTime"
@ -109,23 +112,24 @@
<b-field v-if="isNew">
<b-button native-type="submit" type="is-primary"
:loading="loading.campaigns">Continue</b-button>
:loading="loading.campaigns">{{ $t('campaigns.continue') }}</b-button>
</b-field>
</form>
</div>
<div class="column is-4 is-offset-1">
<br />
<div class="box">
<h3 class="title is-size-6">Send test message</h3>
<b-field message="Hit Enter after typing an address to add multiple recipients.
The addresses must belong to existing subscribers.">
<h3 class="title is-size-6">{{ $t('campaigns.sendTest') }}</h3>
<b-field :message="$t('campaigns.sendTestHelp')">
<b-taginput v-model="form.testEmails"
:before-adding="$utils.validateEmail" :disabled="this.isNew"
ellipsis icon="email-outline" placeholder="E-mails"></b-taginput>
ellipsis icon="email-outline" :placeholder="$t('campaigns.testEmails')" />
</b-field>
<b-field>
<b-button @click="sendTest" :loading="loading.campaigns" :disabled="this.isNew"
type="is-primary" icon-left="email-outline">Send</b-button>
type="is-primary" icon-left="email-outline">
{{ $t('campaigns.send') }}
</b-button>
</b-field>
</div>
</div>
@ -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;
}

View file

@ -2,20 +2,20 @@
<section class="campaigns">
<header class="columns">
<div class="column is-two-thirds">
<h1 class="title is-4">Campaigns
<h1 class="title is-4">{{ $t('globals.terms.campaigns') }}
<span v-if="!isNaN(campaigns.total)">({{ campaigns.total }})</span>
</h1>
</div>
<div class="column has-text-right">
<b-button :to="{name: 'campaign', params:{id: 'new'}}" tag="router-link"
type="is-primary" icon-left="plus">New</b-button>
type="is-primary" icon-left="plus">{{ $t('globals.buttons.new') }}</b-button>
</div>
</header>
<form @submit.prevent="getCampaigns">
<b-field grouped>
<b-input v-model="queryParams.query"
placeholder="Name or subject" icon="magnify" ref="query"></b-input>
:placeholder="$t('campaigns.queryPlaceholder')" icon="magnify" ref="query"></b-input>
<b-button native-type="submit" type="is-primary" icon-left="magnify"></b-button>
</b-field>
</form>
@ -40,7 +40,7 @@
</router-link>
</p>
<p v-if="isSheduled(props.row)">
<b-tooltip label="Scheduled" type="is-dark">
<b-tooltip :label="$t('scheduled')" type="is-dark">
<span class="is-size-7 has-text-grey scheduled">
<b-icon icon="alarm" size="is-small" />
{{ $utils.duration(Date(), props.row.sendAt, true) }}
@ -50,7 +50,7 @@
</p>
</div>
</b-table-column>
<b-table-column field="name" label="Name" sortable width="25%">
<b-table-column field="name" :label="$t('globals.fields.name')" sortable width="25%">
<div>
<p>
<b-tag v-if="props.row.type !== 'regular'" class="is-small">
@ -65,7 +65,8 @@
</b-taglist>
</div>
</b-table-column>
<b-table-column class="lists" field="lists" label="Lists" width="15%">
<b-table-column class="lists" field="lists"
:label="$t('globals.terms.lists')" width="15%">
<ul class="no">
<li v-for="l in props.row.lists" :key="l.id">
<router-link :to="{name: 'subscribers_list', params: { listID: l.id }}">
@ -74,7 +75,8 @@
</li>
</ul>
</b-table-column>
<b-table-column field="created_at" label="Timestamps" width="19%" sortable>
<b-table-column field="created_at" :label="$t('campaigns.timestamps')"
width="19%" sortable>
<div class="fields timestamps" :set="stats = getCampaignStats(props.row)">
<p>
<label>Created</label>
@ -99,15 +101,15 @@
<b-table-column field="stats" :class="props.row.status" label="Stats" width="18%">
<div class="fields stats" :set="stats = getCampaignStats(props.row)">
<p>
<label>Views</label>
<label>{{ $t('campaigns.views') }}</label>
{{ props.row.views }}
</p>
<p>
<label>Clicks</label>
<label>{{ $t('campaigns.clicks') }}</label>
{{ props.row.clicks }}
</p>
<p>
<label>Sent</label>
<label>{{ $t('campaigns.sent') }}</label>
{{ stats.sent }} / {{ stats.toSend }}
</p>
<p title="Speed" v-if="stats.rate">
@ -117,7 +119,7 @@
</span>
</p>
<p v-if="isRunning(props.row.id)">
<label>Progress
<label>{{ $t('campaigns.progress') }}
<span class="spinner is-tiny">
<b-loading :is-full-page="false" active />
</span>
@ -132,52 +134,52 @@
<a href="" v-if="canStart(props.row)"
@click.prevent="$utils.confirm(null,
() => changeCampaignStatus(props.row, 'running'))">
<b-tooltip label="Start" type="is-dark">
<b-tooltip :label="$t('campaigns.start')" type="is-dark">
<b-icon icon="rocket-launch-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" v-if="canPause(props.row)"
@click.prevent="$utils.confirm(null,
() => changeCampaignStatus(props.row, 'paused'))">
<b-tooltip label="Pause" type="is-dark">
<b-tooltip :label="$t('campaigns.pause')" type="is-dark">
<b-icon icon="pause-circle-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" v-if="canResume(props.row)"
@click.prevent="$utils.confirm(null,
() => changeCampaignStatus(props.row, 'running'))">
<b-tooltip label="Send" type="is-dark">
<b-tooltip :label="$t('campaigns.send')" type="is-dark">
<b-icon icon="rocket-launch-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" v-if="canSchedule(props.row)"
@click.prevent="$utils.confirm(`This campaign will start automatically at the
scheduled date and time. Schedule now?`,
@click.prevent="$utils.confirm($t('campaigns.confirmSchedule'),
() => changeCampaignStatus(props.row, 'scheduled'))">
<b-tooltip label="Schedule" type="is-dark">
<b-tooltip :label="$t('campaigns.schedule')" type="is-dark">
<b-icon icon="clock-start" size="is-small" />
</b-tooltip>
</a>
<a href="" @click.prevent="previewCampaign(props.row)">
<b-tooltip label="Preview" type="is-dark">
<b-tooltip :label="$t('campaigns.preview')" type="is-dark">
<b-icon icon="file-find-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" @click.prevent="$utils.prompt(`Clone campaign`,
{ placeholder: 'Name', value: `Copy of ${props.row.name}`},
<a href="" @click.prevent="$utils.prompt($t('globals.buttons.clone'),
{ placeholder: $t('globals.fields.name'),
value: $t('campaigns.copyOf', { name: props.row.name }) },
(name) => cloneCampaign(name, props.row))">
<b-tooltip label="Clone" type="is-dark">
<b-tooltip :label="$t('globals.buttons.clone')" type="is-dark">
<b-icon icon="file-multiple-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" v-if="canCancel(props.row)"
@click.prevent="$utils.confirm(null,
() => changeCampaignStatus(props.row, 'cancelled'))">
<b-tooltip label="Cancel" type="is-dark">
<b-tooltip :label="$t('globals.buttons.cancel')" type="is-dark">
<b-icon icon="cancel" size="is-small" />
</b-tooltip>
</a>
<a href="" @click.prevent="$utils.confirm(`Delete '${props.row.name}'?`,
<a href="" @click.prevent="$utils.confirm($tc('campaigns.confirmDelete'),
() => deleteCampaign(props.row))">
<b-icon icon="trash-can-outline" size="is-small" />
</a>
@ -331,7 +333,7 @@ export default Vue.extend({
changeCampaignStatus(c, status) {
this.$api.changeCampaignStatus(c.id, status).then(() => {
this.$utils.toast(`'${c.name}' is ${status}`);
this.$utils.toast(this.$t('campaigns.statusChanged', { name: c.name, status }));
this.getCampaigns();
this.pollStats();
});
@ -358,7 +360,7 @@ export default Vue.extend({
deleteCampaign(c) {
this.$api.deleteCampaign(c.id).then(() => {
this.getCampaigns();
this.$utils.toast(`'${c.name}' deleted`);
this.$utils.toast(this.$t('globals.messages.deleted', { name: c.name }));
});
},
},

View file

@ -16,23 +16,28 @@
<div class="columns is-mobile">
<div class="column is-6">
<p class="title">{{ $utils.niceNumber(counts.lists.total) }}</p>
<p class="is-size-6 has-text-grey">Lists</p>
<p class="is-size-6 has-text-grey">
{{ $tc('globals.terms.list', counts.lists.total) }}
</p>
</div>
<div class="column is-6">
<ul class="no is-size-7 has-text-grey">
<li>
<label>{{ $utils.niceNumber(counts.lists.public) }}</label> public
<label>{{ $utils.niceNumber(counts.lists.public) }}</label>
{{ $t('lists.types.public') }}
</li>
<li>
<label>{{ $utils.niceNumber(counts.lists.private) }}</label> private
<label>{{ $utils.niceNumber(counts.lists.private) }}</label>
{{ $t('lists.types.private') }}
</li>
<li>
<label>{{ $utils.niceNumber(counts.lists.optinSingle) }}</label>
single opt-in
{{ $t('lists.optins.single') }}
</li>
<li>
<label>{{ $utils.niceNumber(counts.lists.optinDouble) }}</label>
double opt-in</li>
{{ $t('lists.optins.double') }}
</li>
</ul>
</div>
</div>
@ -42,7 +47,9 @@
<div class="columns is-mobile">
<div class="column is-6">
<p class="title">{{ $utils.niceNumber(counts.campaigns.total) }}</p>
<p class="is-size-6 has-text-grey">Campaigns</p>
<p class="is-size-6 has-text-grey">
{{ $tc('globals.terms.campaign', counts.campaigns.total) }}
</p>
</div>
<div class="column is-6">
<ul class="no is-size-7 has-text-grey">
@ -61,18 +68,20 @@
<div class="columns is-mobile">
<div class="column is-6">
<p class="title">{{ $utils.niceNumber(counts.subscribers.total) }}</p>
<p class="is-size-6 has-text-grey">Subscribers</p>
<p class="is-size-6 has-text-grey">
{{ $tc('globals.terms.subscriber', counts.subscribers.total) }}
</p>
</div>
<div class="column is-6">
<ul class="no is-size-7 has-text-grey">
<li>
<label>{{ $utils.niceNumber(counts.subscribers.blocklisted) }}</label>
blocklisted
{{ $t('subscribers.status.blocklisted') }}
</li>
<li>
<label>{{ $utils.niceNumber(counts.subscribers.orphans) }}</label>
orphans
{{ $t('dashboard.orphanSubs') }}
</li>
</ul>
</div><!-- subscriber breakdown -->
@ -81,7 +90,9 @@
<div class="columns">
<div class="column is-6">
<p class="title">{{ $utils.niceNumber(counts.messages) }}</p>
<p class="is-size-6 has-text-grey">Messages sent</p>
<p class="is-size-6 has-text-grey">
{{ $t('dashboard.messagesSent') }}
</p>
</div>
</div>
</article><!-- subscribers -->
@ -92,12 +103,14 @@
<article class="tile is-child notification charts">
<div class="columns">
<div class="column is-6">
<h3 class="title is-size-6">Campaign views</h3><br />
<h3 class="title is-size-6">{{ $t('dashboard.campaignViews') }}</h3><br />
<vue-c3 v-if="chartViewsInst" :handler="chartViewsInst"></vue-c3>
<empty-placeholder v-else-if="!isChartsLoading" />
</div>
<div class="column is-6">
<h3 class="title is-size-6 has-text-right">Link clicks</h3><br />
<h3 class="title is-size-6 has-text-right">
{{ $t('dashboard.linkClicks') }}
</h3><br />
<vue-c3 v-if="chartClicksInst" :handler="chartClicksInst"></vue-c3>
<empty-placeholder v-else-if="!isChartsLoading" />
</div>
@ -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));
});
}
});

View file

@ -1,12 +1,12 @@
<template>
<section class="forms content relative">
<h1 class="title is-4">Forms</h1>
<h1 class="title is-4">{{ $t('forms.title') }}</h1>
<hr />
<b-loading v-if="loading.lists" :active="loading.lists" :is-full-page="false" />
<div class="columns" v-else-if="publicLists.length > 0">
<div class="column is-4">
<h4>Public lists</h4>
<p>Select lists to add to the form.</p>
<h4>{{ $t('forms.publicLists') }}</h4>
<p>{{ $t('forms.selectHelp') }}</p>
<b-loading :active="loading.lists" :is-full-page="false" />
<ul class="no">
@ -17,16 +17,13 @@
</ul>
</div>
<div class="column">
<h4>Form HTML</h4>
<h4>{{ $t('forms.formHTML') }}</h4>
<p>
Use the following HTML to show a subscription form on an external webpage.
</p>
<p>
The form should have the <code>email</code> field and one or more <code>l</code>
(list UUID) fields. The <code>name</code> field is optional.
{{ $t('forms.formHTMLHelp') }}
</p>
<pre><!-- eslint-disable max-len -->&lt;form method=&quot;post&quot; action=&quot;http://localhost:9000/subscription/form&quot; class=&quot;listmonk-form&quot;&gt;
<!-- eslint-disable max-len -->
<pre>&lt;form method=&quot;post&quot; action=&quot;{{ serverConfig.rootURL }}/subscription/form&quot; class=&quot;listmonk-form&quot;&gt;
&lt;div&gt;
&lt;h3&gt;Subscribe&lt;/h3&gt;
&lt;p&gt;&lt;input type=&quot;text&quot; name=&quot;email&quot; placeholder=&quot;E-mail&quot; /&gt;&lt;/p&gt;
@ -42,7 +39,7 @@
</div>
</div><!-- columns -->
<p v-else>There are no public lists to create forms.</p>
<p v-else></p>
</section>
</template>
@ -66,7 +63,7 @@ export default Vue.extend({
},
computed: {
...mapState(['lists', 'loading']),
...mapState(['lists', 'loading', 'serverConfig']),
publicLists() {
if (!this.lists.results) {

View file

@ -1,7 +1,6 @@
<template>
<section class="import">
<h1 class="title is-4">Import subscribers</h1>
<h1 class="title is-4">{{ $t('import.title') }}</h1>
<b-loading :active="isLoading"></b-loading>
<section v-if="isFree()" class="wrap-small">
@ -12,23 +11,23 @@
<b-field label="Mode">
<div>
<b-radio v-model="form.mode" name="mode"
native-value="subscribe">Subscribe</b-radio>
native-value="subscribe">{{ $t('import.subscribe') }}</b-radio>
<b-radio v-model="form.mode" name="mode"
native-value="blocklist">Blocklist</b-radio>
native-value="blocklist">{{ $t('import.blocklist') }}</b-radio>
</div>
</b-field>
</div>
<div class="column">
<b-field v-if="form.mode === 'subscribe'"
label="Overwrite?"
message="Overwrite name and attribs of existing subscribers?">
:label="$t('import.overwrite')"
:message="$t('import.overwriteHelp')">
<div>
<b-switch v-model="form.overwrite" name="overwrite" />
</div>
</b-field>
</div>
<div class="column">
<b-field label="CSV delimiter" message="Default delimiter is comma."
<b-field :label="$t('import.csvDelim')" :message="$t('import.csvDelimHelp')"
class="delimiter">
<b-input v-model="form.delim" name="delim"
placeholder="," maxlength="1" required />
@ -37,22 +36,22 @@
</div>
<list-selector v-if="form.mode === 'subscribe'"
label="Lists"
placeholder="Lists to subscribe to"
message="Lists to subscribe to."
:label="$t('globals.terms.lists')"
:placeholder="$t('import.listSubHelp')"
:message="$t('import.listSubHelp')"
v-model="form.lists"
:selected="form.lists"
:all="lists.results"
></list-selector>
<hr />
<b-field label="CSV or ZIP file" label-position="on-border">
<b-field :label="$t('import.csvFile')" label-position="on-border">
<b-upload v-model="form.file" drag-drop expanded>
<div class="has-text-centered section">
<p>
<b-icon icon="file-upload-outline" size="is-large"></b-icon>
</p>
<p>Click or drag a CSV or ZIP file here</p>
<p>{{ $t('import.csvFileHelp') }}</p>
</div>
</b-upload>
</b-field>
@ -64,20 +63,15 @@
<div class="buttons">
<b-button native-type="submit" type="is-primary"
:disabled="!form.file || (form.mode === 'subscribe' && form.lists.length === 0)"
:loading="isProcessing">Upload</b-button>
:loading="isProcessing">{{ $t('import.upload') }}</b-button>
</div>
</div>
</form>
<br /><br />
<div class="import-help">
<h5 class="title is-size-6">Instructions</h5>
<p>
Upload a CSV file or a ZIP file with a single CSV file in it to bulk
import subscribers. The CSV file should have the following headers
with the exact column names. <code>attributes</code> (optional)
should be a valid JSON string with double escaped quotes.
</p>
<h5 class="title is-size-6">{{ $t('import.instructions') }}</h5>
<p>{{ $t('import.instructionsHelp') }}</p>
<br />
<blockquote className="csv-example">
<code className="csv-headers">
@ -89,7 +83,7 @@
<hr />
<h5 class="title is-size-6">Example raw CSV</h5>
<h5 class="title is-size-6">{{ $t('import.csvExample') }}</h5>
<blockquote className="csv-example">
<code className="csv-headers">
<span>email,</span>
@ -118,12 +112,14 @@
{'has-text-danger': (status.status === 'failed' || status.status === 'stopped')}]">
{{ status.status }}</p>
<p>{{ status.imported }} / {{ status.total }} records</p>
<p>{{ $t('import.recordsCount', { num: status.imported, total: status.total }) }}</p>
<br />
<p>
<b-button @click="stopImport" :loading="isProcessing" icon-left="file-upload-outline"
type="is-primary">{{ isDone() ? 'Done' : 'Stop import' }}</b-button>
type="is-primary">
{{ isDone() ? $t('import.importDone') : $t('import.stopImport') }}
</b-button>
</p>
<br />
@ -281,7 +277,7 @@ export default Vue.extend({
this.$api.importSubscribers(params).then(() => {
// On file upload, show a confirmation.
this.$buefy.toast.open({
message: 'Import started',
message: this.$t('import.importStarted'),
type: 'is-success',
queue: false,
});

View file

@ -3,48 +3,44 @@
<div class="modal-card content" style="width: auto">
<header class="modal-card-head">
<p v-if="isEditing" class="has-text-grey-light is-size-7">
ID: {{ data.id }} / UUID: {{ data.uuid }}
{{ $t('globals.fields.id') }}: {{ data.id }} /
{{ $t('globals.fields.uuid') }}: {{ data.uuid }}
</p>
<b-tag v-if="isEditing" :class="[data.type, 'is-pulled-right']">{{ data.type }}</b-tag>
<h4 v-if="isEditing">{{ data.name }}</h4>
<h4 v-else>New list</h4>
<h4 v-else>{{ $t('lists.newList') }}</h4>
</header>
<section expanded class="modal-card-body">
<b-field label="Name" label-position="on-border">
<b-field :label="$t('globals.fields.name')" label-position="on-border">
<b-input :maxlength="200" :ref="'focus'" v-model="form.name"
placeholder="Name" required></b-input>
:placeholder="$t('globals.fields.name')" required></b-input>
</b-field>
<b-field label="Type" label-position="on-border"
message="Public lists are open to the world to subscribe
and their names may appear on public pages such as the subscription
management page.">
<b-select v-model="form.type" placeholder="Type" required>
<option value="private">Private</option>
<option value="public">Public</option>
<b-field :label="$t('lists.type')" label-position="on-border"
:message="$t('lists.typeHelp')">
<b-select v-model="form.type" :placeholder="$t('lists.typeHelp')" required>
<option value="private">{{ $t('lists.types.private') }}</option>
<option value="public">{{ $t('lists.types.public') }}</option>
</b-select>
</b-field>
<b-field label="Opt-in" label-position="on-border"
message="Double opt-in sends an e-mail to the subscriber asking for
confirmation. On Double opt-in lists, campaigns are only sent to
confirmed subscribers.">
<b-field :label="$t('lists.optin')" label-position="on-border"
:message="$t('lists.optinHelp')">
<b-select v-model="form.optin" placeholder="Opt-in type" required>
<option value="single">Single</option>
<option value="double">Double</option>
<option value="single">{{ $t('lists.optins.single') }}</option>
<option value="double">{{ $t('lists.optins.double') }}</option>
</b-select>
</b-field>
<b-field label="Tags" label-position="on-border">
<b-field :label="$t('globals.terms.tags')" label-position="on-border">
<b-taginput v-model="form.tags" ellipsis
icon="tag-outline" placeholder="Tags"></b-taginput>
icon="tag-outline" :placeholder="$t('globals.terms.tags')"></b-taginput>
</b-field>
</section>
<footer class="modal-card-foot has-text-right">
<b-button @click="$parent.close()">Close</b-button>
<b-button @click="$parent.close()">{{ $t('globals.buttons.close') }}</b-button>
<b-button native-type="submit" type="is-primary"
:loading="loading.lists">Save</b-button>
:loading="loading.lists">{{ $t('globals.buttons.save') }}</b-button>
</footer>
</div>
</form>
@ -89,7 +85,7 @@ export default Vue.extend({
this.$emit('finished');
this.$parent.close();
this.$buefy.toast.open({
message: `'${data.name}' created`,
message: this.$t('globals.messages.created', { name: data.name }),
type: 'is-success',
queue: false,
});
@ -101,7 +97,7 @@ export default Vue.extend({
this.$emit('finished');
this.$parent.close();
this.$buefy.toast.open({
message: `'${data.name}' updated`,
message: this.$t('globals.messages.updated', { name: data.name }),
type: 'is-success',
queue: false,
});

View file

@ -2,12 +2,15 @@
<section class="lists">
<header class="columns">
<div class="column is-two-thirds">
<h1 class="title is-4">Lists
<h1 class="title is-4">
{{ $t('globals.terms.lists') }}
<span v-if="!isNaN(lists.total)">({{ lists.total }})</span>
</h1>
</div>
<div class="column has-text-right">
<b-button type="is-primary" icon-left="plus" @click="showNewForm">New</b-button>
<b-button type="is-primary" icon-left="plus" @click="showNewForm">
{{ $t('globals.buttons.new') }}
</b-button>
</div>
</header>
@ -20,8 +23,9 @@
backend-sorting @sort="onSort"
>
<template slot-scope="props">
<b-table-column field="name" label="Name" sortable width="25%"
paginated backend-pagination pagination-position="both" @page-change="onPageChange">
<b-table-column field="name" :label="$t('globals.fields.name')"
sortable width="25%" paginated backend-pagination pagination-position="both"
@page-change="onPageChange">
<div>
<router-link :to="{name: 'subscribers_list', params: { listID: props.row.id }}">
{{ props.row.name }}
@ -32,15 +36,17 @@
</div>
</b-table-column>
<b-table-column field="type" label="Type" sortable>
<b-table-column field="type" :label="$t('globals.fields.type')" sortable>
<div>
<b-tag :class="props.row.type">{{ props.row.type }}</b-tag>
<b-tag :class="props.row.type">
{{ $t('lists.types.' + props.row.type) }}
</b-tag>
{{ ' ' }}
<b-tag>
<b-icon :icon="props.row.optin === 'double' ?
'account-check-outline' : 'account-off-outline'" size="is-small" />
{{ ' ' }}
{{ props.row.optin }}
{{ $t('lists.optins.' + props.row.optin) }}
</b-tag>{{ ' ' }}
<a v-if="props.row.optin === 'double'" class="is-size-7 send-optin"
href="#" @click="$utils.confirm(null, () => createOptinCampaign(props.row))">
@ -52,33 +58,34 @@
</div>
</b-table-column>
<b-table-column field="subscriber_count" label="Subscribers" numeric sortable centered>
<b-table-column field="subscriber_count" :label="$t('globals.terms.lists')"
numeric sortable centered>
<router-link :to="`/subscribers/lists/${props.row.id}`">
{{ props.row.subscriberCount }}
</router-link>
</b-table-column>
<b-table-column field="created_at" label="Created" sortable>
<b-table-column field="created_at" :label="$t('globals.fields.createdAt')" sortable>
{{ $utils.niceDate(props.row.createdAt) }}
</b-table-column>
<b-table-column field="updated_at" label="Updated" sortable>
<b-table-column field="updated_at" :label="$t('globals.fields.updatedAt')" sortable>
{{ $utils.niceDate(props.row.updatedAt) }}
</b-table-column>
<b-table-column class="actions" align="right">
<div>
<router-link :to="`/campaigns/new?list_id=${props.row.id}`">
<b-tooltip label="Send campaign" type="is-dark">
<b-tooltip :label="$t('lists.sendCampaign')" type="is-dark">
<b-icon icon="rocket-launch-outline" size="is-small" />
</b-tooltip>
</router-link>
<a href="" @click.prevent="showEditForm(props.row)">
<b-tooltip label="Edit" type="is-dark">
<b-tooltip :label="$t('globals.buttons.edit')" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" @click.prevent="deleteList(props.row)">
<b-tooltip label="Delete" type="is-dark">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
@ -165,13 +172,13 @@ export default Vue.extend({
deleteList(list) {
this.$utils.confirm(
'Are you sure? This does not delete subscribers.',
this.$t('lists.confirmDelete'),
() => {
this.$api.deleteList(list.id).then(() => {
this.getLists();
this.$buefy.toast.open({
message: `'${list.name}' deleted`,
message: this.$t('globals.messages.deleted', { name: list.name }),
type: 'is-success',
queue: false,
});
@ -182,8 +189,8 @@ export default Vue.extend({
createOptinCampaign(list) {
const data = {
name: `Opt-in to ${list.name}`,
subject: `Confirm subscription(s) ${list.name}`,
name: this.$t('lists.optinTo', { name: list.name }),
subject: this.$t('lists.confirmSub', { name: list.name }),
lists: [list.id],
from_email: this.serverConfig.fromEmail,
content_type: 'richtext',

View file

@ -1,6 +1,6 @@
<template>
<section class="logs content relative">
<h1 class="title is-4">Logs</h1>
<h1 class="title is-4">{{ $t('logs.title') }}</h1>
<hr />
<log-view :loading="loading.logs" :lines="lines"></log-view>
</section>

View file

@ -1,6 +1,6 @@
<template>
<section class="media-files">
<h1 class="title is-4">Media
<h1 class="title is-4">{{ $t('media.title') }}
<span v-if="media.length > 0">({{ media.length }})</span>
<span class="has-text-grey-light"> / {{ serverConfig.mediaProvider }}</span>
@ -11,7 +11,7 @@
<section class="wrap-small">
<form @submit.prevent="onSubmit" class="box">
<div>
<b-field label="Upload image">
<b-field :label="$t('media.uploadImage')">
<b-upload
v-model="form.files"
drag-drop
@ -22,7 +22,7 @@
<p>
<b-icon icon="file-upload-outline" size="is-large"></b-icon>
</p>
<p>Click or drag one or more images here</p>
<p>{{ $t('media.uploadHelp') }}</p>
</div>
</b-upload>
</b-field>
@ -35,7 +35,7 @@
<div class="buttons">
<b-button native-type="submit" type="is-primary" icon-left="file-upload-outline"
:disabled="form.files.length === 0"
:loading="isProcessing">Upload</b-button>
:loading="isProcessing">{{ $tc('media.upload') }}</b-button>
</div>
</div>
</form>

View file

@ -3,12 +3,12 @@
<b-loading :is-full-page="true" v-if="isLoading" active />
<header class="columns">
<div class="column is-half">
<h1 class="title is-4">Settings</h1>
<h1 class="title is-4">{{ $t('settings.title') }}</h1>
</div>
<div class="column has-text-right">
<b-button :disabled="!hasFormChanged"
type="is-primary" icon-left="content-save-outline"
@click="onSubmit" class="isSaveEnabled">Save changes</b-button>
@click="onSubmit" class="isSaveEnabled">{{ $t('globals.buttons.save') }}</b-button>
</div>
</header>
<hr />
@ -16,85 +16,78 @@
<section class="wrap-small">
<form @submit.prevent="onSubmit">
<b-tabs type="is-boxed" :animated="false">
<b-tab-item label="General" label-position="on-border">
<b-tab-item :label="$t('settings.general.name')" label-position="on-border">
<div class="items">
<b-field label="Root URL" label-position="on-border"
message="Public URL of the installation (no trailing slash).">
<b-field :label="$t('settings.general.rootURL')" label-position="on-border"
:message="$t('settings.general.rootURLHelp')">
<b-input v-model="form['app.root_url']" name="app.root_url"
placeholder='https://listmonk.yoursite.com' :maxlength="300" />
</b-field>
<b-field label="Logo URL" label-position="on-border"
message="(Optional) full URL to the static logo to be displayed on
user facing view such as the unsubscription page.">
<b-field :label="$t('settings.general.logoURL')" label-position="on-border"
:message="$t('settings.general.logoURLHelp')">
<b-input v-model="form['app.logo_url']" name="app.logo_url"
placeholder='https://listmonk.yoursite.com/logo.png' :maxlength="300" />
</b-field>
<b-field label="Favicon URL" label-position="on-border"
message="(Optional) full URL to the static favicon to be displayed on
user facing view such as the unsubscription page.">
<b-field :label="$t('settings.general.faviconURL')" label-position="on-border"
:message="$t('settings.general.faviconURLHelp')">
<b-input v-model="form['app.favicon_url']" name="app.favicon_url"
placeholder='https://listmonk.yoursite.com/favicon.png' :maxlength="300" />
</b-field>
<hr />
<b-field label="Default 'from' email" label-position="on-border"
message="(Optional) full URL to the static logo to be displayed on
user facing view such as the unsubscription page.">
<b-field :label="$t('settings.general.fromEmail')" label-position="on-border"
:message="$t('settings.general.fromEmailHelp')">
<b-input v-model="form['app.from_email']" name="app.from_email"
placeholder='Listmonk <noreply@listmonk.yoursite.com>'
pattern="(.+?)\s<(.+?)@(.+?)>" :maxlength="300" />
</b-field>
<b-field label="Admin notification e-mails" label-position="on-border"
message="Comma separated list of e-mail addresses to which admin
notifications such as import updates, campaign completion,
failure etc. should be sent.">
<b-field :label="$t('settings.general.adminNotifEmails')" label-position="on-border"
:message="$t('settings.general.adminNotifEmailsHelp')">
<b-taginput v-model="form['app.notify_emails']" name="app.notify_emails"
:before-adding="(v) => v.match(/(.+?)@(.+?)/)"
placeholder='you@yoursite.com' />
</b-field>
<hr />
<b-field :label="$t('settings.general.language')" label-position="on-border">
<b-select v-model="form['app.lang']" name="app.lang">
<option v-for="l in serverConfig.langs" :key="l.code" :value="l.code">
{{ l.name }}
</option>
</b-select>
</b-field>
</div>
</b-tab-item><!-- general -->
<b-tab-item label="Performance">
<b-tab-item :label="$t('settings.performance.name')">
<div class="items">
<b-field label="Concurrency" label-position="on-border"
message="Maximum concurrent worker (threads) that will attempt to send messages
simultaneously.">
<b-field :label="$t('settings.performance.concurrency')" label-position="on-border"
:message="$t('settings.performance.concurrencyHelp')">
<b-numberinput v-model="form['app.concurrency']"
name="app.concurrency" type="is-light"
placeholder="5" min="1" max="10000" />
</b-field>
<b-field label="Message rate" label-position="on-border"
message="Maximum number of messages to be sent out per second
per worker in a second. If concurrency = 10 and message_rate = 10,
then up to 10x10=100 messages may be pushed out every second.
This, along with concurrency, should be tweaked to keep the
net messages going out per second under the target
message servers rate limits if any.">
<b-field :label="$t('settings.performance.messageRate')" label-position="on-border"
:message="$t('settings.performance.messageRateHelp')">
<b-numberinput v-model="form['app.message_rate']"
name="app.message_rate" type="is-light"
placeholder="5" min="1" max="100000" />
</b-field>
<b-field label="Batch size" label-position="on-border"
message="The number of subscribers to pull from the databse in a single iteration.
Each iteration pulls subscribers from the database, sends messages to them,
and then moves on to the next iteration to pull the next batch.
This should ideally be higher than the maximum achievable
throughput (concurrency * message_rate).">
<b-field :label="$t('settings.performance.batchSize')" label-position="on-border"
:message="$t('settings.performance.batchSizeHelp')">
<b-numberinput v-model="form['app.batch_size']"
name="app.batch_size" type="is-light"
placeholder="1000" min="1" max="100000" />
</b-field>
<b-field label="Maximum error threshold" label-position="on-border"
message="The number of errors (eg: SMTP timeouts while e-mailing) a running
campaign should tolerate before it is paused for manual
investigation or intervention. Set to 0 to never pause.">
<b-field :label="$t('settings.performance.maxErrThreshold')"
label-position="on-border"
:message="$t('settings.performance.maxErrThresholdHelp')">
<b-numberinput v-model="form['app.max_send_errors']"
name="app.max_send_errors" type="is-light"
placeholder="1999" min="0" max="100000" />
@ -102,42 +95,34 @@
</div>
</b-tab-item><!-- performance -->
<b-tab-item label="Privacy">
<b-tab-item :label="$t('settings.privacy.name')">
<div class="items">
<b-field label="Individual subscriber tracking"
message="Track subscriber-level campaign views and clicks.
When disabled, view and click tracking continue without
being linked to individual subscribers.">
<b-field :label="$t('settings.privacy.individualSubTracking')"
:message="$t('settings.privacy.individualSubTrackingHelp')">
<b-switch v-model="form['privacy.individual_tracking']"
name="privacy.individual_tracking" />
</b-field>
<b-field label="Include `List-Unsubscribe` header"
message="Include unsubscription headers that allow e-mail clients to
allow users to unsubscribe in a single click.">
<b-field :label="$t('settings.privacy.listUnsubHeader')"
:message="$t('settings.privacy.listUnsubHeaderHelp')">
<b-switch v-model="form['privacy.unsubscribe_header']"
name="privacy.unsubscribe_header" />
</b-field>
<b-field label="Allow blocklisting"
message="Allow subscribers to unsubscribe from all mailing lists and mark
themselves as blocklisted?">
<b-field :label="$t('settings.privacy.allowBlocklist')"
:message="$t('settings.privacy.allowBlocklist')">
<b-switch v-model="form['privacy.allow_blocklist']"
name="privacy.allow_blocklist" />
</b-field>
<b-field label="Allow exporting"
message="Allow subscribers to export data collected on them?">
<b-field :label="$t('settings.privacy.allowExport')"
:message="$t('settings.privacy.allowExportHelp')">
<b-switch v-model="form['privacy.allow_export']"
name="privacy.allow_export" />
</b-field>
<b-field label="Allow wiping"
message="Allow subscribers to delete themselves including their
subscriptions and all other data from the database.
Campaign views and link clicks are also
removed while views and click counts remain (with no subscriber
associated to them) so that stats and analytics aren't affected.">
<b-field :label="$t('settings.privacy.allowWipe')"
message="$t('settings.privacy.allowWipeHelp')">
<b-switch v-model="form['privacy.allow_wipe']"
name="privacy.allow_wipe" />
</b-field>
@ -146,7 +131,7 @@
<b-tab-item label="Media uploads">
<div class="items">
<b-field label="Provider" label-position="on-border">
<b-field :label="$t('settings.media.provider')" label-position="on-border">
<b-select v-model="form['upload.provider']" name="upload.provider">
<option value="filesystem">filesystem</option>
<option value="s3">s3</option>
@ -154,17 +139,15 @@
</b-field>
<div class="block" v-if="form['upload.provider'] === 'filesystem'">
<b-field label="Upload path" label-position="on-border"
message="Path to the directory where media will be uploaded.">
<b-field :label="$t('settings.media.upload.path')" label-position="on-border"
:message="$t('settings.media.upload.pathHelp')">
<b-input v-model="form['upload.filesystem.upload_path']"
name="app.upload_path" placeholder='/home/listmonk/uploads'
:maxlength="200" />
</b-field>
<b-field label="Upload URI" label-position="on-border"
message="Upload URI that's visible to the outside world.
The media uploaded to upload_path will be publicly accessible
under {root_url}/{}, for instance, https://listmonk.yoursite.com/uploads.">
<b-field :label="$t('settings.media.upload.uri')" label-position="on-border"
:message="$t('settings.media.upload.uriHelp')">
<b-input v-model="form['upload.filesystem.upload_uri']"
name="app.upload_uri" placeholder='/uploads' :maxlength="200" />
</b-field>
@ -173,7 +156,8 @@
<div class="block" v-if="form['upload.provider'] === 's3'">
<div class="columns">
<div class="column is-3">
<b-field label="Region" label-position="on-border" expanded>
<b-field :label="$t('settings.media.s3.region')"
label-position="on-border" expanded>
<b-input v-model="form['upload.s3.aws_default_region']"
name="upload.s3.aws_default_region"
:maxlength="200" placeholder="ap-south-1" />
@ -181,11 +165,13 @@
</div>
<div class="column">
<b-field grouped>
<b-field label="AWS access key" label-position="on-border" expanded>
<b-field :label="$t('settings.media.s3.key')"
label-position="on-border" expanded>
<b-input v-model="form['upload.s3.aws_access_key_id']"
name="upload.s3.aws_access_key_id" :maxlength="200" />
</b-field>
<b-field label="AWS access secret" label-position="on-border" expanded
<b-field :label="$t('settings.media.s3.secret')"
label-position="on-border" expanded
message="Enter a value to change.">
<b-input v-model="form['upload.s3.aws_secret_access_key']"
name="upload.s3.aws_secret_access_key" type="password"
@ -197,22 +183,28 @@
<div class="columns">
<div class="column is-3">
<b-field label="Bucket type" label-position="on-border">
<b-field :label="$t('settings.media.s3.bucketType')" label-position="on-border">
<b-select v-model="form['upload.s3.bucket_type']"
name="upload.s3.bucket_type" expanded>
<option value="private">private</option>
<option value="public">public</option>
<option value="private">
{{ $t('settings.media.s3.bucketTypePrivate') }}
</option>
<option value="public">
{{ $t('settings.media.s3.bucketTypePublic') }}
</option>
</b-select>
</b-field>
</div>
<div class="column">
<b-field grouped>
<b-field label="Bucket" label-position="on-border" expanded>
<b-field :label="$t('settings.media.s3.bucket')"
label-position="on-border" expanded>
<b-input v-model="form['upload.s3.bucket']"
name="upload.s3.bucket" :maxlength="200" placeholder="" />
</b-field>
<b-field label="Bucket path" label-position="on-border"
message="Path inside the bucket to upload files. Default is /" expanded>
<b-field :label="$t('settings.media.s3.bucketPath')"
label-position="on-border"
:message="$t('settings.media.s3.bucketPathHelp')" expanded>
<b-input v-model="form['upload.s3.bucket_path']"
name="upload.s3.bucket_path" :maxlength="200" placeholder="/" />
</b-field>
@ -221,10 +213,9 @@
</div>
<div class="columns">
<div class="column is-3">
<b-field label="Upload expiry" label-position="on-border"
message="(Optional) Specify TTL (in seconds) for the generated presigned URL.
Only applicable for private buckets
(s, m, h, d for seconds, minutes, hours, days)." expanded>
<b-field :label="$t('settings.media.s3.uploadExpiry')"
label-position="on-border"
:message="$t('settings.media.s3.uploadExpiryHelp')" expanded>
<b-input v-model="form['upload.s3.expiry']"
name="upload.s3.expiry"
placeholder="14d" :pattern="regDuration" :maxlength="10" />
@ -235,19 +226,20 @@
</div>
</b-tab-item><!-- media -->
<b-tab-item label="SMTP">
<b-tab-item :label="$t('settings.smtp.name')">
<div class="items mail-servers">
<div class="block box" v-for="(item, n) in form.smtp" :key="n">
<div class="columns">
<div class="column is-2">
<b-field label="Enabled">
<b-field :label="$t('globals.buttons.enabled')">
<b-switch v-model="item.enabled" name="enabled"
:native-value="true" />
</b-field>
<b-field v-if="form.smtp.length > 1">
<a @click.prevent="$utils.confirm(null, () => removeSMTP(n))"
href="#" class="is-size-7">
<b-icon icon="trash-can-outline" size="is-small" /> Delete
<b-icon icon="trash-can-outline" size="is-small" />
{{ $t('globals.buttons.delete') }}
</a>
</b-field>
</div><!-- first column -->
@ -255,15 +247,15 @@
<div class="column" :class="{'disabled': !item.enabled}">
<div class="columns">
<div class="column is-8">
<b-field label="Host" label-position="on-border"
message="SMTP server's host address.">
<b-field :label="$t('settings.smtp.host')" label-position="on-border"
:message="$t('settings.smtp.hostHelp')">
<b-input v-model="item.host" name="host"
placeholder='smtp.yourmailserver.net' :maxlength="200" />
</b-field>
</div>
<div class="column">
<b-field label="Port" label-position="on-border"
message="SMTP server's port.">
<b-field :label="$t('settings.smtp.port')" label-position="on-border"
:message="$t('settings.smtp.portHelp')">
<b-numberinput v-model="item.port" name="port" type="is-light"
controls-position="compact"
placeholder="25" min="1" max="65535" />
@ -273,7 +265,8 @@
<div class="columns">
<div class="column is-2">
<b-field label="Auth protocol" label-position="on-border">
<b-field :label="$t('settings.smtp.authProtocol')"
label-position="on-border">
<b-select v-model="item.auth_protocol" name="auth_protocol">
<option value="none">none</option>
<option value="cram">cram</option>
@ -284,16 +277,19 @@
</div>
<div class="column">
<b-field grouped>
<b-field label="Username" label-position="on-border" expanded>
<b-field :label="$t('settings.smtp.username')"
label-position="on-border" expanded>
<b-input v-model="item.username"
:disabled="item.auth_protocol === 'none'"
name="username" placeholder="mysmtp" :maxlength="200" />
</b-field>
<b-field label="Password" label-position="on-border" expanded
message="Enter a value to change.">
<b-field :label="$t('settings.smtp.password')"
label-position="on-border" expanded
:message="$t('settings.smtp.passwordHelp')">
<b-input v-model="item.password"
:disabled="item.auth_protocol === 'none'"
name="password" type="password" placeholder="Enter to change"
name="password" type="password"
:placeholder="$t('settings.smtp.passwordHelp')"
:maxlength="200" />
</b-field>
</b-field>
@ -303,22 +299,20 @@
<div class="columns">
<div class="column is-6">
<b-field label="HELO hostname" label-position="on-border"
message="Optional. Some SMTP servers require a FQDN in the hostname.
By default, HELLOs go with 'localhost'. Set this if a custom
hostname should be used.">
<b-field :label="$t('settings.smtp.heloHost')" label-position="on-border"
:message="$t('settings.smtp.heloHostHelp')">
<b-input v-model="item.hello_hostname"
name="hello_hostname" placeholder="" :maxlength="200" />
</b-field>
</div>
<div class="column">
<b-field grouped>
<b-field label="TLS" expanded
message="Enable STARTTLS.">
<b-field :label="$t('settings.smtp.tls')" expanded
:message="$t('settings.smtp.tlsHelp')">
<b-switch v-model="item.tls_enabled" name="item.tls_enabled" />
</b-field>
<b-field label="Skip TLS verification" expanded
message="Skip hostname check on the TLS certificate.">
<b-field :label="$t('settings.smtp.tls')" expanded
:message="$t('settings.smtp.tlsHelp')">
<b-switch v-model="item.tls_skip_verify"
:disabled="!item.tls_enabled" name="item.tls_skip_verify" />
</b-field>
@ -329,16 +323,16 @@
<div class="columns">
<div class="column is-3">
<b-field label="Max. connections" label-position="on-border"
message="Maximum concurrent connections to the SMTP server.">
<b-field :label="$t('settings.smtp.maxConns')" label-position="on-border"
:message="$t('settings.smtp.maxConnsHelp')">
<b-numberinput v-model="item.max_conns" name="max_conns" type="is-light"
controls-position="compact"
placeholder="25" min="1" max="65535" />
</b-field>
</div>
<div class="column is-3">
<b-field label="Retries" label-position="on-border"
message="Number of times to rety when a message fails.">
<b-field :label="$t('settings.smtp.retries')" label-position="on-border"
:message="$t('settings.smtp.retriesHelp')">
<b-numberinput v-model="item.max_msg_retries" name="max_msg_retries"
type="is-light"
controls-position="compact"
@ -346,17 +340,15 @@
</b-field>
</div>
<div class="column is-3">
<b-field label="Idle timeout" label-position="on-border"
message="Time to wait for new activity on a connection before closing
it and removing it from the pool (s for second, m for minute).">
<b-field :label="$t('settings.smtp.idleTimeout')" label-position="on-border"
:message="$t('settings.smtp.idleTimeoutHelp')">
<b-input v-model="item.idle_timeout" name="idle_timeout"
placeholder="15s" :pattern="regDuration" :maxlength="10" />
</b-field>
</div>
<div class="column is-3">
<b-field label="Wait timeout" label-position="on-border"
message="Time to wait for new activity on a connection before closing
it and removing it from the pool (s for second, m for minute).">
<b-field :label="$t('settings.smtp.waitTimeout')" label-position="on-border"
:message="$t('settings.smtp.waitTimeoutHelp')">
<b-input v-model="item.wait_timeout" name="wait_timeout"
placeholder="5s" :pattern="regDuration" :maxlength="10" />
</b-field>
@ -367,13 +359,11 @@
<div>
<p v-if="item.email_headers.length === 0 && !item.showHeaders">
<a href="#" class="is-size-7" @click.prevent="() => showSMTPHeaders(n)">
<b-icon icon="plus" />Set custom headers</a>
<b-icon icon="plus" />{{ $t('settings.smtp.setCustomHeaders') }}</a>
</p>
<b-field v-if="item.email_headers.length > 0 || item.showHeaders"
label="Custom headers" label-position="on-border"
message='Optional array of e-mail headers to include in all messages
sent from this server.
eg: [{"X-Custom": "value"}, {"X-Custom2": "value"}]'>
:label="$t('')" label-position="on-border"
:message="$t('settings.smtp.customHeadersHelp')">
<b-input v-model="item.strEmailHeaders" name="email_headers" type="textarea"
placeholder='[{"X-Custom": "value"}, {"X-Custom2": "value"}]' />
</b-field>
@ -383,22 +373,25 @@
</div><!-- block -->
</div><!-- mail-servers -->
<b-button @click="addSMTP" icon-left="plus" type="is-primary">Add new</b-button>
<b-button @click="addSMTP" icon-left="plus" type="is-primary">
{{ $t('globals.buttons.addNew') }}
</b-button>
</b-tab-item><!-- mail servers -->
<b-tab-item label="Messengers">
<b-tab-item :label="$t('settings.messengers.name')">
<div class="items messengers">
<div class="block box" v-for="(item, n) in form.messengers" :key="n">
<div class="columns">
<div class="column is-2">
<b-field label="Enabled">
<b-field :label="$t('globals.buttons.enabled')">
<b-switch v-model="item.enabled" name="enabled"
:native-value="true" />
</b-field>
<b-field>
<a @click.prevent="$utils.confirm(null, () => removeMessenger(n))"
href="#" class="is-size-7">
<b-icon icon="trash-can-outline" size="is-small" /> Delete
<b-icon icon="trash-can-outline" size="is-small" />
{{ $t('globals.buttons.delete') }}
</a>
</b-field>
</div><!-- first column -->
@ -406,15 +399,15 @@
<div class="column" :class="{'disabled': !item.enabled}">
<div class="columns">
<div class="column is-4">
<b-field label="Name" label-position="on-border"
message="eg: my-sms. Alphanumeric / dash.">
<b-field :label="$t('globals.fields.name')" label-position="on-border"
:message="$t('settings.messengers.nameHelp')">
<b-input v-model="item.name" name="name"
placeholder='mymessenger' :maxlength="200" />
</b-field>
</div>
<div class="column is-8">
<b-field label="URL" label-position="on-border"
message="Root URL of the Postback server.">
<b-field :label="$t('settings.messengers.url')" label-position="on-border"
:message="$t('settings.messengers.urlHelp')">
<b-input v-model="item.root_url" name="root_url"
placeholder='https://postback.messenger.net/path' :maxlength="200" />
</b-field>
@ -424,13 +417,16 @@
<div class="columns">
<div class="column">
<b-field grouped>
<b-field label="Username" label-position="on-border" expanded>
<b-field :label="$t('settings.messengers.username')"
label-position="on-border" expanded>
<b-input v-model="item.username" name="username" :maxlength="200" />
</b-field>
<b-field label="Password" label-position="on-border" expanded
message="Enter a value to change.">
<b-field :label="$t('settings.messengers.password')"
label-position="on-border" expanded
:message="$t('globals.messages.passwordChange')">
<b-input v-model="item.password"
name="password" type="password" placeholder="Enter to change"
name="password" type="password"
:placeholder="$t('globals.messages.passwordChange')"
:maxlength="200" />
</b-field>
</b-field>
@ -440,16 +436,18 @@
<div class="columns">
<div class="column is-4">
<b-field label="Max. connections" label-position="on-border"
message="Maximum concurrent connections to the server.">
<b-field :label="$t('settings.messengers.maxConns')"
label-position="on-border"
:message="$t('settings.messengers.maxConnsHelp')">
<b-numberinput v-model="item.max_conns" name="max_conns" type="is-light"
controls-position="compact"
placeholder="25" min="1" max="65535" />
</b-field>
</div>
<div class="column is-4">
<b-field label="Retries" label-position="on-border"
message="Number of times to rety when a message fails.">
<b-field :label="$t('settings.messengers.retries')"
label-position="on-border"
:message="$t('settings.messengers.retriesHelp')">
<b-numberinput v-model="item.max_msg_retries" name="max_msg_retries"
type="is-light"
controls-position="compact"
@ -457,8 +455,9 @@
</b-field>
</div>
<div class="column is-4">
<b-field label="Request imeout" label-position="on-border"
message="Request timeout duration (s for second, m for minute).">
<b-field :label="$t('settings.messengers.timeout')"
label-position="on-border"
:message="$t('settings.messengers.timeoutHelp')">
<b-input v-model="item.timeout" name="timeout"
placeholder="5s" :pattern="regDuration" :maxlength="10" />
</b-field>
@ -470,7 +469,9 @@
</div><!-- block -->
</div><!-- mail-servers -->
<b-button @click="addMessenger" icon-left="plus" type="is-primary">Add new</b-button>
<b-button @click="addMessenger" icon-left="plus" type="is-primary">
{{ $t('globals.buttons.addNew') }}
</b-button>
</b-tab-item><!-- messengers -->
</b-tabs>
@ -587,7 +588,7 @@ export default Vue.extend({
return;
}
this.$utils.toast('Settings saved. Reloading app ...');
this.$utils.toast(this.$t('settings.messengers.messageSaved'));
// Poll until there's a 200 response, waiting for the app
// to restart and come back up.
@ -645,7 +646,7 @@ export default Vue.extend({
beforeRouteLeave(to, from, next) {
if (this.hasFormChanged) {
this.$utils.confirm('Discard changes?', () => next(true));
this.$utils.confirm(this.$t('settings.messengers.messageDiscard'), () => next(true));
return;
}
next(true);

View file

@ -2,19 +2,23 @@
<form @submit.prevent="onSubmit">
<div class="modal-card" style="width: auto">
<header class="modal-card-head">
<h4 class="title is-size-5">Manage lists</h4>
<h4 class="title is-size-5">{{ $t('subscribers.manageLists') }}</h4>
</header>
<section expanded class="modal-card-body">
<b-field label="Action">
<div>
<b-radio v-model="form.action" name="action" native-value="add">Add</b-radio>
<b-radio v-model="form.action" name="action" native-value="remove">Remove</b-radio>
<b-radio v-model="form.action" name="action" native-value="add">
{{ $t('globals.buttons.add') }}
</b-radio>
<b-radio v-model="form.action" name="action" native-value="remove">
{{ $t('globals.buttons.remove') }}
</b-radio>
<b-radio
v-model="form.action"
name="action"
native-value="unsubscribe"
>Mark as unsubscribed</b-radio>
>{{ $t('subscribers.markUnsubscribed') }}</b-radio>
</div>
</b-field>
@ -28,9 +32,9 @@
</section>
<footer class="modal-card-foot has-text-right">
<b-button @click="$parent.close()">Close</b-button>
<b-button @click="$parent.close()">{{ $t('globals.buttons.close') }}</b-button>
<b-button native-type="submit" type="is-primary"
:disabled="form.lists.length === 0">Save</b-button>
:disabled="form.lists.length === 0">{{ $t('globals.buttons.save') }}</b-button>
</footer>
</div>
</form>

View file

@ -5,53 +5,54 @@
<b-tag v-if="isEditing" :class="[data.status, 'is-pulled-right']">{{ data.status }}</b-tag>
<h4 v-if="isEditing">{{ data.name }}</h4>
<h4 v-else>New subscriber</h4>
<h4 v-else>{{ $t('subscribers.newSubscriber') }}</h4>
<p v-if="isEditing" class="has-text-grey is-size-7">
ID: {{ data.id }} / UUID: {{ data.uuid }}
{{ $t('globals.fields.id') }}: {{ data.id }} /
{{ $t('globals.fields.uuid') }}: {{ data.uuid }}
</p>
</header>
<section expanded class="modal-card-body">
<b-field label="E-mail" label-position="on-border">
<b-field :label="$t('subscribers.email')" label-position="on-border">
<b-input :maxlength="200" v-model="form.email" :ref="'focus'"
placeholder="E-mail" required></b-input>
:placeholder="$t('subscribers.email')" required></b-input>
</b-field>
<b-field label="Name" label-position="on-border">
<b-input :maxlength="200" v-model="form.name" placeholder="Name"></b-input>
<b-field :label="$t('globals.fields.name')" label-position="on-border">
<b-input :maxlength="200" v-model="form.name"
:placeholder="$t('globals.fields.name')"></b-input>
</b-field>
<b-field label="Status" label-position="on-border"
message="Blocklisted subscribers will never receive any e-mails.">
<b-field :label="Status" label-position="on-border"
:message="$t('subscribers.blocklistedHelp')">
<b-select v-model="form.status" placeholder="Status" required>
<option value="enabled">Enabled</option>
<option value="blocklisted">Blocklisted</option>
<option value="enabled">{{ $t('subscribers.status.enabled') }}</option>
<option value="blocklisted">{{ $t('subscribers.status.blocklisted') }}</option>
</b-select>
</b-field>
<list-selector
label="Lists"
placeholder="Lists to subscribe to"
message="Lists from which subscribers have unsubscribed themselves cannot be removed."
:label="$t('subscribers.lists')"
:placeholder="$t('subscribers.listsPlaceholder')"
:message="$t('subscribers.listsHelp')"
v-model="form.lists"
:selected="form.lists"
:all="lists.results"
></list-selector>
<b-field label="Attributes" label-position="on-border"
message='Attributes are defined as a JSON map, for example:
{"job": "developer", "location": "Mars", "has_rocket": true}.'>
<b-field :label="$t('subscribers.attribs')" label-position="on-border"
:message="$t('subscribers.attribsHelp') + ' ' + egAttribs">
<b-input v-model="form.strAttribs" type="textarea" />
</b-field>
<a href="https://listmonk.app/docs/concepts"
target="_blank" rel="noopener noreferrer" class="is-size-7">
Learn more <b-icon icon="link" size="is-small" />.
{{ $t('globals.buttons.learnMore') }} <b-icon icon="link" size="is-small" />.
</a>
</section>
<footer class="modal-card-foot has-text-right">
<b-button @click="$parent.close()">Close</b-button>
<b-button @click="$parent.close()">{{ $t('globals.buttons.close') }}</b-button>
<b-button native-type="submit" type="is-primary"
:loading="loading.subscribers">Save</b-button>
:loading="loading.subscribers">{{ $t('globals.buttons.save') }}</b-button>
</footer>
</div>
</form>
@ -80,6 +81,8 @@ export default Vue.extend({
// Binds form input values. This is populated by subscriber props passed
// from the parent component in mounted().
form: { lists: [], strAttribs: '{}' },
egAttribs: '{"job": "developer", "location": "Mars", "has_rocket": true}',
};
},
@ -113,7 +116,7 @@ export default Vue.extend({
this.$emit('finished');
this.$parent.close();
this.$buefy.toast.open({
message: `'${d.name}' created`,
message: this.$t('globals.messages.created', { name: d.name }),
type: 'is-success',
queue: false,
});
@ -141,7 +144,7 @@ export default Vue.extend({
this.$emit('finished');
this.$parent.close();
this.$buefy.toast.open({
message: `'${d.name}' updated`,
message: this.$t('globals.messages.updated', { name: d.name }),
type: 'is-success',
queue: false,
});
@ -155,7 +158,7 @@ export default Vue.extend({
attribs = JSON.parse(str);
} catch (e) {
this.$buefy.toast.open({
message: `Invalid JSON in attributes: ${e.toString()}`,
message: `${this.$t('subscribers.invalidJSON')}: e.toString()`,
type: 'is-danger',
duration: 3000,
queue: false,

View file

@ -2,7 +2,7 @@
<section class="subscribers">
<header class="columns">
<div class="column is-half">
<h1 class="title is-4">Subscribers
<h1 class="title is-4">{{ $t('globals.terms.subscribers') }}
<span v-if="!isNaN(subscribers.total)">({{ subscribers.total }})</span>
<span v-if="currentList">
&raquo; {{ currentList.name }}
@ -10,7 +10,9 @@
</h1>
</div>
<div class="column has-text-right">
<b-button type="is-primary" icon-left="plus" @click="showNewForm">New</b-button>
<b-button type="is-primary" icon-left="plus" @click="showNewForm">
{{ $t('globals.buttons.new') }}
</b-button>
</div>
</header>
@ -20,7 +22,7 @@
<div>
<b-field grouped>
<b-input @input="onSimpleQueryInput" v-model="queryInput"
placeholder="E-mail or name" icon="magnify" ref="query"
:placeholder="$t('subscribers.queryPlaceholder')" icon="magnify" ref="query"
:disabled="isSearchAdvanced"></b-input>
<b-button native-type="submit" type="is-primary" icon-left="magnify"
:disabled="isSearchAdvanced"></b-button>
@ -28,7 +30,9 @@
<p>
<a href="#" @click.prevent="toggleAdvancedSearch">
<b-icon icon="cog-outline" size="is-small" /> Advanced</a>
<b-icon icon="cog-outline" size="is-small" />
{{ $t('subscribers.advancedQuery') }}
</a>
</p>
<div v-if="isSearchAdvanced">
@ -41,17 +45,20 @@
</b-field>
<b-field>
<span class="is-size-6 has-text-grey">
Partial SQL expression to query subscriber attributes.{{ ' ' }}
{{ $t('subscribers.advancedQueryHelp') }}.{{ ' ' }}
<a href="https://listmonk.app/docs/querying-and-segmentation"
target="_blank" rel="noopener noreferrer"> Learn more.
target="_blank" rel="noopener noreferrer">
{{ $t('globals.buttons.learnMore') }}.
</a>
</span>
</b-field>
<div class="buttons">
<b-button native-type="submit" type="is-primary"
icon-left="magnify">Query</b-button>
<b-button @click.prevent="toggleAdvancedSearch" icon-left="cancel">Reset</b-button>
icon-left="magnify">{{ $t('subscribers.query') }}</b-button>
<b-button @click.prevent="toggleAdvancedSearch" icon-left="cancel">
{{ $t('subscribers.reset') }}
</b-button>
</div>
</div><!-- advanced query -->
</div>
@ -62,11 +69,13 @@
<div>
<p>
<span class="is-size-5 has-text-weight-semibold">
{{ numSelectedSubscribers }} subscriber(s) selected
{{ $t('subscribers.numSelected', { num: numSelectedSubscribers }) }}
</span>
<span v-if="!bulk.all && subscribers.total > subscribers.perPage">
&mdash; <a href="" @click.prevent="selectAllSubscribers">
Select all {{ subscribers.total }}</a>
&mdash;
<a href="" @click.prevent="selectAllSubscribers">
{{ $t('subscribers.selectAll', { num: subscribers.total }) }}
</a>
</span>
</p>
@ -99,7 +108,9 @@
<b-table-column field="status" label="Status" sortable>
<a :href="`/subscribers/${props.row.id}`"
@click.prevent="showEditForm(props.row)">
<b-tag :class="props.row.status">{{ props.row.status }}</b-tag>
<b-tag :class="props.row.status">
{{ $t('subscribers.status.'+ props.row.status) }}
</b-tag>
</a>
</b-table-column>
@ -112,7 +123,8 @@
<router-link :to="`/subscribers/lists/${props.row.id}`">
<b-tag :class="l.subscriptionStatus" v-for="l in props.row.lists"
size="is-small" :key="l.id">
{{ l.name }} <sup>{{ l.subscriptionStatus }}</sup>
{{ l.name }}
<sup>{{ $t('subscribers.status.'+ l.subscriptionStatus) }}</sup>
</b-tag>
</router-link>
</b-taglist>
@ -140,18 +152,18 @@
<b-table-column class="actions" align="right">
<div>
<a :href="`/api/subscribers/${props.row.id}/export`">
<b-tooltip label="Download data" type="is-dark">
<b-tooltip :label="$t('subscribers.downloadData')" type="is-dark">
<b-icon icon="cloud-download-outline" size="is-small" />
</b-tooltip>
</a>
<a :href="`/subscribers/${props.row.id}`"
@click.prevent="showEditForm(props.row)">
<b-tooltip label="Edit" type="is-dark">
<b-tooltip :label="$t('globals.buttons.edit')" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" />
</b-tooltip>
</a>
<a href='' @click.prevent="deleteSubscriber(props.row)">
<b-tooltip label="Delete" type="is-dark">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
@ -326,7 +338,7 @@ export default Vue.extend({
this.querySubscribers();
this.$buefy.toast.open({
message: `'${sub.name}' deleted.`,
message: this.$t('globals.messages.deleted', { name: sub.name }),
type: 'is-success',
queue: false,
});
@ -354,10 +366,7 @@ export default Vue.extend({
};
}
this.$utils.confirm(
`Blocklist ${this.numSelectedSubscribers} subscriber(s)?`,
fn,
);
this.$utils.confirm(this.$t('subscribers.confirmBlocklist', { num: this.numSelectedSubscribers }), fn);
},
deleteSubscribers() {
@ -371,7 +380,7 @@ export default Vue.extend({
this.querySubscribers();
this.$buefy.toast.open({
message: `${this.numSelectedSubscribers} subscriber(s) deleted`,
message: this.$t('subscribers.subscribersDeleted', { num: this.numSelectedSubscribers }),
type: 'is-success',
queue: false,
});
@ -387,7 +396,7 @@ export default Vue.extend({
this.querySubscribers();
this.$buefy.toast.open({
message: `${this.numSelectedSubscribers} subscriber(s) deleted`,
message: this.$t('subscribers.subscribersDeleted', { num: this.numSelectedSubscribers }),
type: 'is-success',
queue: false,
});
@ -395,10 +404,7 @@ export default Vue.extend({
};
}
this.$utils.confirm(
`Delete ${this.numSelectedSubscribers} subscriber(s)?`,
fn,
);
this.$utils.confirm(this.$t('subscribers.confirmDelete', { num: this.numSelectedSubscribers }), fn);
},
bulkChangeLists(action, lists) {
@ -422,7 +428,7 @@ export default Vue.extend({
fn(data).then(() => {
this.querySubscribers();
this.$buefy.toast.open({
message: 'List change applied',
message: this.$t('subscribers.listChangeApplied'),
type: 'is-success',
queue: false,
});

View file

@ -5,31 +5,32 @@
<header class="modal-card-head">
<b-button @click="previewTemplate"
class="is-pulled-right" type="is-primary"
icon-left="file-find-outline">Preview</b-button>
icon-left="file-find-outline">{{ $t('templates.preview') }}</b-button>
<h4 v-if="isEditing">{{ data.name }}</h4>
<h4 v-else>New template</h4>
<h4 v-else>{{ $t('templates.newTemplate') }}</h4>
</header>
<section expanded class="modal-card-body">
<b-field label="Name" label-position="on-border">
<b-field :label="$t('globals.fields.name')" label-position="on-border">
<b-input :maxlength="200" :ref="'focus'" v-model="form.name"
placeholder="Name" required></b-input>
placeholder="$t('globals.fields.name')" required></b-input>
</b-field>
<b-field label="Raw HTML" label-position="on-border">
<b-field :label="$t('globals.fields.rawHTML')" label-position="on-border">
<b-input v-model="form.body" type="textarea" required />
</b-field>
<p class="is-size-7">
The placeholder <code>{{ egPlaceholder }}</code>
should appear in the template.
<a target="_blank" href="https://listmonk.app/docs/templating">Learn more.</a>
{{ $t('templates.placeholderHelp', { placeholder: egPlaceholder }) }}
<a target="_blank" href="https://listmonk.app/docs/templating">
{{ $t('globals.buttons.learnMore') }}
</a>
</p>
</section>
<footer class="modal-card-foot has-text-right">
<b-button @click="$parent.close()">Close</b-button>
<b-button @click="$parent.close()">{{ $t('globals.buttons.close') }}</b-button>
<b-button native-type="submit" type="is-primary"
:loading="loading.templates">Save</b-button>
:loading="loading.templates">{{ $t('globals.buttons.save') }}</b-button>
</footer>
</div>
</form>
@ -98,7 +99,7 @@ export default Vue.extend({
this.$emit('finished');
this.$parent.close();
this.$buefy.toast.open({
message: `'${d.name}' created`,
message: this.$t('globals.messages.created', { name: d.name }),
type: 'is-success',
queue: false,
});

View file

@ -2,60 +2,62 @@
<section class="templates">
<header class="columns">
<div class="column is-two-thirds">
<h1 class="title is-4">Templates
<h1 class="title is-4">{{ $t('globals.terms.templates') }}
<span v-if="templates.length > 0">({{ templates.length }})</span></h1>
</div>
<div class="column has-text-right">
<b-button type="is-primary" icon-left="plus" @click="showNewForm">New</b-button>
<b-button type="is-primary" icon-left="plus" @click="showNewForm">
{{ $t('globals.buttons.new') }}
</b-button>
</div>
</header>
<b-table :data="templates" :hoverable="true" :loading="loading.templates"
default-sort="createdAt">
<template slot-scope="props">
<b-table-column field="name" label="Name" sortable>
<b-table-column field="name" :label="$t('globals.fields.name')" sortable>
<a :href="props.row.id" @click.prevent="showEditForm(props.row)">
{{ props.row.name }}
</a>
<b-tag v-if="props.row.isDefault">default</b-tag>
<b-tag v-if="props.row.isDefault">{{ $t('templates.default') }}</b-tag>
</b-table-column>
<b-table-column field="createdAt" label="Created" sortable>
<b-table-column field="createdAt" :label="$t('globals.fields.createdAt')" sortable>
{{ $utils.niceDate(props.row.createdAt) }}
</b-table-column>
<b-table-column field="updatedAt" label="Updated" sortable>
<b-table-column field="updatedAt" :label="$t('globals.fields.updatedAt')" sortable>
{{ $utils.niceDate(props.row.updatedAt) }}
</b-table-column>
<b-table-column class="actions" align="right">
<div>
<a href="#" @click.prevent="previewTemplate(props.row)">
<b-tooltip label="Preview" type="is-dark">
<b-tooltip :label="$t('templates.preview')" type="is-dark">
<b-icon icon="file-find-outline" size="is-small" />
</b-tooltip>
</a>
<a href="#" @click.prevent="showEditForm(props.row)">
<b-tooltip label="Edit" type="is-dark">
<b-tooltip :label="$t('globals.buttons.edit')" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" @click.prevent="$utils.prompt(`Clone template`,
{ placeholder: 'Name', value: `Copy of ${props.row.name}`},
(name) => cloneTemplate(name, props.row))">
<b-tooltip label="Clone" type="is-dark">
<b-tooltip :label="$t('globals.buttons.clone')" type="is-dark">
<b-icon icon="file-multiple-outline" size="is-small" />
</b-tooltip>
</a>
<a v-if="!props.row.isDefault" href="#"
@click.prevent="$utils.confirm(null, () => makeTemplateDefault(props.row))">
<b-tooltip label="Make default" type="is-dark">
<b-tooltip :label="$t('templates.makeDefault')" type="is-dark">
<b-icon icon="check-circle-outline" size="is-small" />
</b-tooltip>
</a>
<a v-if="!props.row.isDefault"
href="#" @click.prevent="$utils.confirm(null, () => deleteTemplate(props.row))">
<b-tooltip label="Delete" type="is-dark">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
@ -151,7 +153,7 @@ export default Vue.extend({
this.$api.getTemplates();
this.$buefy.toast.open({
message: `'${tpl.name}' made default`,
message: this.$t('globals.messages.created', { name: tpl.name }),
type: 'is-success',
queue: false,
});
@ -163,7 +165,7 @@ export default Vue.extend({
this.$api.getTemplates();
this.$buefy.toast.open({
message: `'${tpl.name}' deleted`,
message: this.$t('globals.messages.deleted', { name: tpl.name }),
type: 'is-success',
queue: false,
});

5
frontend/yarn.lock vendored
View file

@ -8986,6 +8986,11 @@ vue-hot-reload-api@^2.3.0:
resolved "https://registry.yarnpkg.com/vue-hot-reload-api/-/vue-hot-reload-api-2.3.4.tgz#532955cc1eb208a3d990b3a9f9a70574657e08f2"
integrity sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog==
vue-i18n@^8.22.2:
version "8.22.2"
resolved "https://registry.yarnpkg.com/vue-i18n/-/vue-i18n-8.22.2.tgz#58299a5a050e67b4f799d96fee7dd8bd269e0907"
integrity sha512-rb569fVJInPUgS/bbCxEQ9DrAoFTntuJvYoK4Fpk2VfNbA09WzdTKk57ppjz3S+ps9hW+p9H+2ASgMvojedkow==
vue-loader@^15.9.2:
version "15.9.2"
resolved "https://registry.yarnpkg.com/vue-loader/-/vue-loader-15.9.2.tgz#ae01f5f4c9c6a04bff4483912e72ef91a402c1ae"

2
go.mod
View file

@ -27,3 +27,5 @@ require (
gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b
jaytaylor.com/html2text v0.0.0-20200220170450-61d9dc4d7195
)
replace github.com/knadh/smtppool => /home/kailash/code/go/my/knadh/smtp-pool

363
i18n/en.json Normal file
View file

@ -0,0 +1,363 @@
{
"_.code": "en",
"_.name": "English (en)",
"admin.errorMarshallingConfig": "Error marshalling config: {error}",
"campaigns.cantUpdate": "Cannot update a running or a finished campaign",
"campaigns.clicks": "Clicks",
"campaigns.confirmDelete": "Delete {name}",
"campaigns.confirmSchedule": "This campaign will start automatically at the scheduled date and time.Schedule now?",
"campaigns.continue": "Continue",
"campaigns.copyOf": "Copy of {name}",
"campaigns.dateAndTime": "Date and time",
"campaigns.errorSendTest": "Error sending test: {error}",
"campaigns.fieldInvalidBody": "Error compiling campaign body: {error}",
"campaigns.fieldInvalidFromEmail": "Invalid `from_email`.",
"campaigns.fieldInvalidListIDs": "Invalid list IDs.",
"campaigns.fieldInvalidMessenger": "Unknown messenger {name}.",
"campaigns.fieldInvalidName": "Invalid length for `name`.",
"campaigns.fieldInvalidSendAt": "`send_at` date should be in the future.",
"campaigns.fieldInvalidSubject": "Invalid length for `subject`.",
"campaigns.fromAddress": "From address",
"campaigns.fromAddressPlaceholder": "Your Name <noreply@yoursite.com>",
"campaigns.invalid": "Invalid campaign",
"campaigns.needsSendAt": "Campaign needs a `send_at` date to be scheduled.",
"campaigns.newCampaign": "New campaign",
"campaigns.noKnownSubsToTest": "No known subscribers to test.",
"campaigns.noOptinLists": "No opt-in lists found to create campaign.",
"campaigns.noSubs": "There are no subscribers in the selected lists to create the campaign.",
"campaigns.noSubsToTest": "There are no subscribers to target.",
"campaigns.notFound": "Campaign not found.",
"campaigns.onlyActiveCancel": "Only active campaigns can be cancelled.",
"campaigns.onlyActivePause": "Only active campaigns can be paused.",
"campaigns.onlyDraftAsScheduled": "Only draft campaigns can be scheduled.",
"campaigns.onlyPausedDraft": "Only paused campaigns and drafts can be started.",
"campaigns.onlyScheduledAsDraft": "Only scheduled campaigns can be saved as drafts.",
"campaigns.pause": "Pause",
"campaigns.preview": "Preview",
"campaigns.progress": "Progress",
"campaigns.queryPlaceholder": "Name or subject",
"campaigns.schedule": "Schedule campaign",
"campaigns.scheduled": "Scheduled",
"campaigns.send": "Send",
"campaigns.sendLater": "Send later",
"campaigns.sendTest": "Send test message",
"campaigns.sendTestHelp": "Hit Enter after typing an address to add multiple recipients. The addresses must belong to existing subscribers.",
"campaigns.sendToLists": "Lists to send to",
"campaigns.sent": "Sent",
"campaigns.start": "Start campaign",
"campaigns.started": "\"{name}\" started",
"campaigns.statusChanged": "\"{name}\" is {status}",
"campaigns.subject": "Subject",
"campaigns.testEmails": "E-mails",
"campaigns.testSent": "Test message sent",
"campaigns.timestamps": "Timestamps",
"campaigns.views": "Views",
"dashboard.campaignViews": "Campaign views",
"dashboard.linkClicks": "Link clicks",
"dashboard.messagesSent": "Messages sent",
"dashboard.orphanSubs": "Orphans",
"email.data.info": "A copy of all data recorded on you is attached as a file in JSON format. It can be viewed in a text editor.",
"email.data.title": "Your data",
"email.optin.confirmSub": "Confirm subscription",
"email.optin.confirmSubHelp": "Confirm your subscription by clicking the below button.",
"email.optin.confirmSubInfo": "You have been added to the following lists:",
"email.optin.confirmSubTitle": "Confirm subscription",
"email.optin.confirmSubWelcome": "Hi {name},",
"email.optin.privateList": "Private list",
"email.status.campaignReason": "Reason",
"email.status.campaignSent": "Sent",
"email.status.campaignTitle": "Campaign update",
"email.status.importFile": "File",
"email.status.importRecords": "Records",
"email.status.importTitle": "Import update",
"email.status.status": "Status",
"email.unsub": "Unsubscribe",
"email.unsubHelp": "Don't want to receive these e-mails?",
"forms.formHTML": "Form HTML",
"forms.formHTMLHelp": "Use the following HTML to show a subscription form on an external webpage. The form should have the `email` field and one or more `l` (list UUID) fields. The `name` field is optional.",
"forms.publicLists": "Public lists",
"forms.selectHelp": "Select lists to add to the form.",
"forms.title": "Forms",
"globals.buttons.add": "Add",
"globals.buttons.addNew": "Add new",
"globals.buttons.cancel": "Cancel",
"globals.buttons.clone": "Clone",
"globals.buttons.close": "Close",
"globals.buttons.delete": "Delete",
"globals.buttons.edit": "Edit",
"globals.buttons.enabled": "Enabled",
"globals.buttons.learnMore": "Learn more",
"globals.buttons.new": "New",
"globals.buttons.ok": "Ok",
"globals.buttons.remove": "Remove",
"globals.buttons.save": "Save",
"globals.buttons.saveChanges": "Save changes",
"globals.fields.createdAt": "Created",
"globals.fields.id": "ID",
"globals.fields.name": "Name",
"globals.fields.status": "Status",
"globals.fields.type": "Type",
"globals.fields.updatedAt": "Updated",
"globals.fields.uuid": "UUID",
"globals.messages.confirm": "Are you sure?",
"globals.messages.created": "\"{name}\" created",
"globals.messages.deleted": "\"{name}\" deleted",
"globals.messages.emptyState": "Nothing here",
"globals.messages.errorCreating": "Error creating {name}: {error}",
"globals.messages.errorDeleting": "Error deleting {name}: {error}",
"globals.messages.errorFetching": "Error fetching {name}: {error}",
"globals.messages.errorUUID": "Error generating UUID: {error}",
"globals.messages.errorUpdating": "Error updating {name}: {error}",
"globals.messages.invalidID": "Invalid ID",
"globals.messages.invalidUUID": "Invalid UUID",
"globals.messages.notFound": "{name} not found",
"globals.messages.passwordChange": "Enter a value to change",
"globals.messages.updated": "\"{name}\" updated",
"globals.terms.campaign": "Campaign | Campaigns",
"globals.terms.campaigns": "Campaigns",
"globals.terms.dashboard": "Dashboard",
"globals.terms.list": "List | Lists",
"globals.terms.lists": "Lists",
"globals.terms.media": "Media | Media",
"globals.terms.messenger": "Messenger | Messengers",
"globals.terms.messengers": "Messengers",
"globals.terms.settings": "Settings",
"globals.terms.subscriber": "Subscriber | Subscribers",
"globals.terms.subscribers": "Subscribers",
"globals.terms.tag": "Tag | Tags",
"globals.terms.tags": "Tags",
"globals.terms.template": "Template | Templates",
"globals.terms.templates": "Templates",
"import.alreadyRunning": "An import is already running. Wait for it to finish or stop it before trying again.",
"import.blocklist": "Blocklist",
"import.csvDelim": "CSV delimiter",
"import.csvDelimHelp": "Default delimiter is comma.",
"import.csvExample": "Example raw CSV",
"import.csvFile": "CSV or ZIP file",
"import.csvFileHelp": "Click or drag a CSV or ZIP file here",
"import.errorCopyingFile": "Error copying file: {error}",
"import.errorProcessingZIP": "Error processing ZIP file: {error}",
"import.errorStarting": "Error starting import: {error}",
"import.importDone": "Done",
"import.importStarted": "Import started",
"import.instructions": "Instructions",
"import.instructionsHelp": "Upload a CSV file or a ZIP file with a single CSV file in it to bulk import subscribers. The CSV file should have the following headers with the exact column names. attributes (optional) should be a valid JSON string with double escaped quotes.",
"import.invalidDelim": "`delim` should be a single character",
"import.invalidFile": "Invalid file: {error}",
"import.invalidMode": "Invalid mode",
"import.invalidParams": "Invalid params: {error}",
"import.listSubHelp": "Lists to subscribe to.",
"import.overwrite": "Overwrite?",
"import.overwriteHelp": "Overwrite name and attribs of existing subscribers?",
"import.recordsCount": "{num} / {total} records",
"import.stopImport": "Stop import",
"import.subscribe": "Subscribe",
"import.title": "Import subscribers",
"import.upload": "Upload",
"lists.confirmDelete": "Are you sure? This does not delete subscribers.",
"lists.confirmSub": "Confirm subscription(s) to {name}",
"lists.invalidName": "Invalid name",
"lists.newList": "New list",
"lists.optin": "Opt-in",
"lists.optinHelp": "Double opt-in sends an e-mail to the subscriber asking for confirmation. On Double opt-in lists, campaigns are only sent to confirmed subscribers.",
"lists.optinTo": "Opt-in to {name}",
"lists.optins.double": "Double opt-in",
"lists.optins.single": "Single opt-in",
"lists.sendCampaign": "Send campaign",
"lists.type": "Type",
"lists.typeHelp": "Public lists are open to the world to subscribe and their names may appear on public pages such as the subscription management page.",
"lists.types.private": "Private",
"lists.types.public": "Public",
"logs.title": "Logs",
"media.errorReadingFile": "Error reading file: {error}",
"media.errorResizing": "Error resizing image: {error}",
"media.errorSavingThumbnail": "Error saving thumbnail: {error}",
"media.errorUploading": "Error uploading file: {error}",
"media.invalidFile": "Invalid file: {error}",
"media.title": "Media",
"media.unsupportedFileType": "Unsupported file type ({type})",
"media.upload": "Upload",
"media.uploadHelp": "Click or drag one or more images here",
"media.uploadImage": "Upload image",
"menu.allCampaigns": "All campaigns",
"menu.allLists": "All lists",
"menu.allSubscribers": "All subscribers",
"menu.dashboard": "Dashboard",
"menu.forms": "Forms",
"menu.logs": "Logs",
"menu.media": "Media",
"menu.newCampaign": "Create new",
"menu.settings": "Settings",
"public.campaignNotFound": "The e-mail message was not found.",
"public.confirmOptinSubTitle": "Confirm subscription",
"public.confirmSub": "Confirm subscription",
"public.confirmSubInfo": "You have been added to the following lists:",
"public.confirmSubTitle": "Confirm",
"public.dataRemoved": "Your subscriptions and all associated data has been removed.",
"public.dataRemovedTitle": "Data removed",
"public.dataSent": "Your data has been e-mailed to you as an attachment",
"public.dataSentTitle": "Data e-mailed",
"public.errorFetchingCampaign": "Error fetching e-mail message",
"public.errorFetchingEmail": "E-mail message not found",
"public.errorFetchingLists": "Error fetching lists. Please retry.",
"public.errorProcessingRequest": "Error processing request. Please retry.",
"public.errorTitle": "Error",
"public.invalidFeature": "That feature is not available",
"public.invalidLink": "Invalid link",
"public.noSubInfo": "There are no subscriptions to confirm",
"public.noSubTitle": "No subscriptions",
"public.notFoundTitle": "Not found",
"public.subConfirmed": "Subscribed successfully",
"public.subConfirmedTitle": "Confirmed",
"public.subPrivateList": "Private list",
"public.unsubbedInfo": "You have unsubscribed successfully",
"public.unsubbedTitle": "Unsubscribed",
"public.unsubscribeTitle": "Unsubscribe from mailing list",
"settings.duplicateMessengerName": "Duplicate messenger name: {name}",
"settings.errorEncoding": "Error encoding settings: {error}",
"settings.errorNoSMTP": "At least one SMTP block should be enabled",
"settings.general.adminNotifEmails": "Admin notification e-mails",
"settings.general.adminNotifEmailsHelp": "Comma separated list of e-mail addresses to which admin notifications such as import updates, campaign completion, failure etc. should be sent.",
"settings.general.faviconURL": "Favicon URL",
"settings.general.faviconURLHelp": "(Optional) full URL to the static favicon to be displayed on user facing view such as the unsubscription page.",
"settings.general.fromEmail": "Default `from` email",
"settings.general.fromEmailHelp": "(Optional) full URL to the static logo to be displayed on user facing view such as the unsubscription page.",
"settings.general.language": "Language",
"settings.general.logoURL": "Root URL",
"settings.general.logoURLHelp": "(Optional) full URL to the static logo to be displayed on user facing view such as the unsubscription page.",
"settings.general.name": "General",
"settings.general.rootURL": "Root URL",
"settings.general.rootURLHelp": "Public URL of the installation (no trailing slash).",
"settings.invalidMessengerName": "Invalid messenger name",
"settings.media.provider": "Provider",
"settings.media.s3.bucket": "Bucket",
"settings.media.s3.bucketPath": "Bucket path",
"settings.media.s3.bucketPathHelp": "Path inside the bucket to upload files. Default is /",
"settings.media.s3.bucketType": "Bucket type",
"settings.media.s3.bucketTypePrivate": "Private",
"settings.media.s3.bucketTypePublic": "Public",
"settings.media.s3.key": "AWS access key",
"settings.media.s3.region": "Region",
"settings.media.s3.secret": "AWS access secret",
"settings.media.s3.uploadExpiry": "Upload expiry",
"settings.media.s3.uploadExpiryHelp": "(Optional) Specify TTL (in seconds) for the generated presigned URL. Only applicable for private buckets (s, m, h, d for seconds, minutes, hours, days).",
"settings.media.title": "Media uploads",
"settings.media.upload.path": "Upload path",
"settings.media.upload.pathHelp": "Path to the directory where media will be uploaded.",
"settings.media.upload.uri": "Upload URI",
"settings.media.upload.uriHelp": "Upload URI that is visible to the outside world. The media uploaded to upload_path will be publicly accessible under {root_url}, for instance, https://listmonk.yoursite.com/uploads.",
"settings.messengers.maxConns": "Max. connections",
"settings.messengers.maxConnsHelp": "Maximum concurrent connections to the SMTP server.",
"settings.messengers.messageDiscard": "Discard changes?",
"settings.messengers.messageSaved": "Settings saved. Reloading app ...",
"settings.messengers.name": "Messengers",
"settings.messengers.nameHelp": "eg: my-sms. Alphanumeric / dash.",
"settings.messengers.password": "Password",
"settings.messengers.retries": "Retries",
"settings.messengers.retriesHelp": "Number of times to rety when a message fails.",
"settings.messengers.skipTLSHelp": "Skip hostname check on the TLS certificate.",
"settings.messengers.timeout": "Idle timeout",
"settings.messengers.timeoutHelp": "Time to wait for new activity on a connection before closing it and removing it from the pool (s for second, m for minute).",
"settings.messengers.url": "URL",
"settings.messengers.urlHelp": "Root URL of the Postback server.",
"settings.messengers.username": "Username",
"settings.performance.batchSize": "Batch size",
"settings.performance.batchSizeHelp": "The number of subscribers to pull from the databse in a single iteration. Each iteration pulls subscribers from the database, sends messages to them, and then moves on to the next iteration to pull the next batch. This should ideally be higher than the maximum achievable throughput (concurrency * message_rate).",
"settings.performance.concurrency": "Concurrency",
"settings.performance.concurrencyHelp": "Maximum concurrent worker (threads) that will attempt to send messages simultaneously.",
"settings.performance.maxErrThreshold": "Maximum error threshold",
"settings.performance.maxErrThresholdHelp": "The number of errors (eg: SMTP timeouts while e-mailing) a running campaign should tolerate before it is paused for manual investigation or intervention. Set to 0 to never pause.",
"settings.performance.messageRate": "Message rate",
"settings.performance.messageRateHelp": "Maximum number of messages to be sent out per second per worker in a second. If concurrency = 10 and message_rate = 10, then up to 10x10=100 messages may be pushed out every second. This, along with concurrency, should be tweaked to keep the net messages going out per second under the target message servers rate limits if any.",
"settings.performance.name": "Performance",
"settings.privacy.allowBlocklist": "Allow blocklisting",
"settings.privacy.allowBlocklistHelp": "Allow subscribers to unsubscribe from all mailing lists and mark themselves as blocklisted?",
"settings.privacy.allowExport": "Allow exporting",
"settings.privacy.allowExportHelp": "Allow subscribers to export data collected on them?",
"settings.privacy.allowWipe": "Allow wiping",
"settings.privacy.allowWipeHelp": "Allow subscribers to delete themselves including their subscriptions and all other data from the database. Campaign views and link clicks are also removed while views and click counts remain (with no subscriber associated to them) so that stats and analytics are not affected.",
"settings.privacy.individualSubTracking": "Individual subscriber tracking",
"settings.privacy.individualSubTrackingHelp": "Track subscriber-level campaign views and clicks. When disabled, view and click tracking continue without being linked to individual subscribers.",
"settings.privacy.listUnsubHeader": "Include `List-Unsubscribe` header",
"settings.privacy.listUnsubHeaderHelp": "Include unsubscription headers that allow e-mail clients to allow users to unsubscribe in a single click.",
"settings.privacy.name": "Privacy",
"settings.smtp.authProtocol": "Auth protocol",
"settings.smtp.customHeaders": "Custom headers",
"settings.smtp.customHeadersHelp": "Optional array of e-mail headers to include in all messages sent from this server. eg: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
"settings.smtp.enabled": "Enabled",
"settings.smtp.heloHost": "HELO hostname",
"settings.smtp.heloHostHelp": "Optional. Some SMTP servers require a FQDN in the hostname. By default, HELLOs go with `localhost`. Set this if a custom hostname should be used.",
"settings.smtp.host": "Host",
"settings.smtp.hostHelp": "SMTP server\"s host address.",
"settings.smtp.idleTimeout": "Idle timeout",
"settings.smtp.idleTimeoutHelp": "Time to wait for new activity on a connection before closing it and removing it from the pool (s for second, m for minute).",
"settings.smtp.maxConns": "Max. connections",
"settings.smtp.maxConnsHelp": "Maximum concurrent connections to the SMTP server.",
"settings.smtp.name": "SMTP",
"settings.smtp.password": "Password",
"settings.smtp.passwordHelp": "Enter to change",
"settings.smtp.port": "Port",
"settings.smtp.portHelp": "SMTP server\"s port.",
"settings.smtp.retries": "Retries",
"settings.smtp.retriesHelp": "Number of times to rety when a message fails.",
"settings.smtp.setCustomHeaders": "Set custom headers",
"settings.smtp.skipTLS": "Skip TLS verification",
"settings.smtp.skipTLSHelp": "Skip hostname check on the TLS certificate.",
"settings.smtp.tls": "TLS",
"settings.smtp.tlsHelp": "Enable STARTTLS.",
"settings.smtp.username": "Username",
"settings.smtp.waitTimeout": "Wait timeout",
"settings.smtp.waitTimeoutHelp": "Time to wait for new activity on a connection before closing it and removing it from the pool(s for second, m for minute).",
"settings.title": "Settings",
"subscribers.advancedQuery": "Advanced",
"subscribers.advancedQueryHelp": "Partial SQL expression to query subscriber attributes",
"subscribers.attribs": "Attributes",
"subscribers.attribsHelp": "Attributes are defined as a JSON map, for example:",
"subscribers.blocklistedHelp": "Blocklisted subscribers will never receive any e-mails.",
"subscribers.confirmBlocklist": "Blocklist {num} subscriber(s)?",
"subscribers.confirmDelete": "Delete {num} subscriber(s)?",
"subscribers.downloadData": "Download data",
"subscribers.email": "E-mail",
"subscribers.emailExists": "E-mail already exists",
"subscribers.errorBlocklisting": "Error blocklisting subscribers: {error}",
"subscribers.errorInvalidIDs": "One or more invalid IDs given: {error}",
"subscribers.errorNoIDs": "No IDs given",
"subscribers.errorNoListsGiven": "No lists given",
"subscribers.errorPreparingQuery": "Error preparing subscriber query: {error}",
"subscribers.errorSendingOptin": "Error sending opt-in e-mail",
"subscribers.invalidAction": "Invalid action",
"subscribers.invalidEmail": "Invalid email",
"subscribers.invalidJSON": "Invalid JSON in attributes",
"subscribers.invalidName": "Invalid name",
"subscribers.listChangeApplied": "List change applied",
"subscribers.lists": "Lists",
"subscribers.listsHelp": "Lists from which subscribers have unsubscribed themselves cannot be removed.",
"subscribers.listsPlaceholder": "Lists to subscriber to",
"subscribers.manageLists": "Manage lists",
"subscribers.markUnsubscribed": "Mark as unsubscribed",
"subscribers.newSubscriber": "New subscriber",
"subscribers.numSelected": "{num} subscriber(s) selected",
"subscribers.optinSubject": "Confirm subscription",
"subscribers.query": "Query",
"subscribers.queryPlaceholder": "E-mail or name",
"subscribers.reset": "Reset",
"subscribers.selectAll": "Select all {num}",
"subscribers.status.blocklisted": "Blocklisted",
"subscribers.status.enabled": "Enabled",
"subscribers.status.subscribed": "Subscribed",
"subscribers.status.unconfirmed": "Unconfirmed",
"subscribers.status.unsubscribed": "Unsubscribed",
"subscribers.subscribersDeleted": "{num} subscriber(s) deleted",
"templates.cantDeleteDefault": "Cannot delete default template",
"templates.default": "Default",
"templates.dummyName": "Dummy campaign",
"templates.dummySubject": "Dummy campaign subject",
"templates.errorCompiling": "Error compiling template: {error}",
"templates.errorRendering": "Error rendering message: {error}",
"templates.fieldInvalidName": "Invalid length for `name`.",
"templates.makeDefault": "Set default",
"templates.newTemplate": "New template",
"templates.placeholderHelp": "The placeholder {placeholder} should appear exactly once in the template.",
"templates.preview": "Preview",
"templates.rawHTML": "Raw HTML"
}

161
internal/i18n/i18n.go Normal file
View file

@ -0,0 +1,161 @@
package i18n
import (
"encoding/json"
"regexp"
"strings"
)
// Lang represents a loaded language.
type Lang struct {
Code string `json:"code"`
Name string `json:"name"`
langMap map[string]string
}
// I18nLang is a simple i18n library that translates strings using a language map.
// It mimicks some functionality of the vue-i18n library so that the same JSON
// language map may be used in the JS frontent and the Go backend.
type I18nLang struct {
Code string `json:"code"`
Name string `json:"name"`
langMap map[string]string
}
var reParam = regexp.MustCompile(`(?i)\{([a-z0-9-.]+)\}`)
// New returns an I18n instance.
func New(code string, b []byte) (*I18nLang, error) {
var l map[string]string
if err := json.Unmarshal(b, &l); err != nil {
return nil, err
}
return &I18nLang{
langMap: l,
}, nil
}
// JSON returns the languagemap as raw JSON.
func (i *I18nLang) JSON() []byte {
b, _ := json.Marshal(i.langMap)
return b
}
// T returns the translation for the given key similar to vue i18n's t().
func (i *I18nLang) T(key string) string {
s, ok := i.langMap[key]
if !ok {
return key
}
return i.getSingular(s)
}
// Ts returns the translation for the given key similar to vue i18n's t()
// and subsitutes the params in the given map in the translated value.
// In the language values, the substitutions are represented as: {key}
func (i *I18nLang) Ts(key string, params map[string]string) string {
s, ok := i.langMap[key]
if !ok {
return key
}
s = i.getSingular(s)
for p, val := range params {
// If there are {params} in the map values, substitute them.
val = i.subAllParams(val)
s = strings.ReplaceAll(s, `{`+p+`}`, val)
}
return s
}
// Ts2 returns the translation for the given key similar to vue i18n's t()
// and subsitutes the params in the given map in the translated value.
// In the language values, the substitutions are represented as: {key}
// The params and values are received as a pairs of succeeding strings.
// That is, the number of these arguments should be an even number.
// eg: Ts2("globals.message.notFound",
// "name", "campaigns",
// "error", err)
func (i *I18nLang) Ts2(key string, params ...string) string {
if len(params)%2 != 0 {
return key + `: Invalid arguments`
}
s, ok := i.langMap[key]
if !ok {
return key
}
s = i.getSingular(s)
for n := 0; n < len(params); n += 2 {
// If there are {params} in the param values, substitute them.
val := i.subAllParams(params[n+1])
s = strings.ReplaceAll(s, `{`+params[n]+`}`, val)
}
return s
}
// Tc returns the translation for the given key similar to vue i18n's tc().
// It expects the language string in the map to be of the form `Singular | Plural` and
// returns `Plural` if n > 1, or `Singular` otherwise.
func (i *I18nLang) Tc(key string, n int) string {
s, ok := i.langMap[key]
if !ok {
return key
}
// Plural.
if n > 1 {
return i.getPlural(s)
}
return i.getSingular(s)
}
// getSingular returns the singular term from the vuei18n pipe separated value.
// singular term | plural term
func (i *I18nLang) getSingular(s string) string {
if !strings.Contains(s, "|") {
return s
}
return strings.TrimSpace(strings.Split(s, "|")[0])
}
// getSingular returns the plural term from the vuei18n pipe separated value.
// singular term | plural term
func (i *I18nLang) getPlural(s string) string {
if !strings.Contains(s, "|") {
return s
}
chunks := strings.Split(s, "|")
if len(chunks) == 2 {
return strings.TrimSpace(chunks[1])
}
return strings.TrimSpace(chunks[0])
}
// subAllParams recursively resolves and replaces all {params} in a string.
func (i *I18nLang) subAllParams(s string) string {
if !strings.Contains(s, `{`) {
return s
}
parts := reParam.FindAllStringSubmatch(s, -1)
if len(parts) < 1 {
return s
}
for _, p := range parts {
s = strings.ReplaceAll(s, p[0], i.T(p[1]))
}
return i.subAllParams(s)
}

View file

@ -0,0 +1,16 @@
package migrations
import (
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf"
"github.com/knadh/stuffbin"
)
// V0_9_0 performs the DB migrations for v.0.9.0.
func V0_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
_, err := db.Exec(`
INSERT INTO settings (key, value) VALUES ('app.lang', '"en"')
ON CONFLICT DO NOTHING;
`)
return err
}

View file

@ -174,6 +174,7 @@ INSERT INTO settings (key, value) VALUES
('app.batch_size', '1000'),
('app.max_send_errors', '1000'),
('app.notify_emails', '["admin1@mysite.com", "admin2@mysite.com"]'),
('app.lang', '"en"'),
('privacy.individual_tracking', 'false'),
('privacy.unsubscribe_header', 'true'),
('privacy.allow_blocklist', 'true'),

View file

@ -1,22 +1,22 @@
{{ define "campaign-status" }}
{{ template "header" . }}
<h2>Campaign update</h2>
<h2>{{ .L.T "email.status.campaignUpdate" }}</h2>
<table width="100%">
<tr>
<td width="30%"><strong>Campaign</strong></td>
<td width="30%"><strong>{{ .L.T "globa.L.Terms.campaign" }}</strong></td>
<td><a href="{{ index . "RootURL" }}/campaigns/{{ index . "ID" }}">{{ index . "Name" }}</a></td>
</tr>
<tr>
<td width="30%"><strong>Status</strong></td>
<td width="30%"><strong>{{ .L.T "email.status.status" }}</strong></td>
<td>{{ index . "Status" }}</td>
</tr>
<tr>
<td width="30%"><strong>Sent</strong></td>
<td width="30%"><strong>{{ .L.T "email.status.campaignSent" }}</strong></td>
<td>{{ index . "Sent" }} / {{ index . "ToSend" }}</td>
</tr>
{{ if ne (index . "Reason") "" }}
<tr>
<td width="30%"><strong>Reason</strong></td>
<td width="30%"><strong>{{ .L.T "email.status.campaignReason" }}</strong></td>
<td>{{ index . "Reason" }}</td>
</tr>
{{ end }}

View file

@ -76,7 +76,10 @@
</div>
<div class="footer" style="text-align: center;font-size: 12px;color: #888;">
<p>Don't want to receive these e-mails? <a href="{{ UnsubscribeURL }}" style="color: #888;">Unsubscribe</a></p>
<p>
{{ I18n.T "email.unsubHelp" }}
<a href="{{ UnsubscribeURL }}" style="color: #888;">{{ I18n.T "email.unsub" }}</a>
</p>
<p>Powered by <a href="https://listmonk.app" target="_blank" style="color: #888;">listmonk</a></p>
</div>
<div class="gutter" style="padding: 30px;">&nbsp;{{ TrackView }}</div>

View file

@ -1,17 +1,17 @@
{{ define "import-status" }}
{{ template "header" . }}
<h2>Import update</h2>
<h2>{{ .L.T "email.status.importTitle" }}</h2>
<table width="100%">
<tr>
<td width="30%"><strong>File</strong></td>
<td width="30%"><strong>{{ .L.T "email.status.importFile" }}</strong></td>
<td><a href="{{ RootURL }}/subscribers/import">{{ .Name }}</a></td>
</tr>
<tr>
<td width="30%"><strong>Status</strong></td>
<td width="30%"><strong>{{ .L.T "email.status.status" }}</strong></td>
<td>{{ .Status }}</td>
</tr>
<tr>
<td width="30%"><strong>Records</strong></td>
<td width="30%"><strong>{{ .L.T "email.status.importRecords" }}</strong></td>
<td>{{ .Imported }} / {{ .Total }}</td>
</tr>
</table>

View file

@ -1,9 +1,8 @@
{{ define "subscriber-data" }}
{{ template "header" . }}
<h2>Your data</h2>
<h2>{{ .L.T "email.data.title" }}</h2>
<p>
A copy of all data recorded on you is attached as a file in JSON format.
It can be viewed in a text editor.
{{ .L.T "email.data.info" }}
</p>
{{ template "footer" }}
{{ end }}

View file

@ -1,17 +1,17 @@
{{ define "optin-campaign" }}
<p>Hi {{`{{ .Subscriber.FirstName }}`}},</p>
<p>You have been added to the following mailing lists:</p>
<p>{{ .L.Ts "email.optin.confirmSubWelcome" "name" .Subscriber.FirstName }}</p>
<p>{{ .L.T "email.optin.confirmSubInfo" }}</p>
<ul>
{{ range $i, $l := .Lists }}
{{ if eq .Type "public" }}
<li>{{ .Name }}</li>
{{ else }}
<li>Private list</li>
<li>{{ .L.T "email.optin.privateList" }}</li>
{{ end }}
{{ end }}
</ul>
<p>
<a class="button" {{ .OptinURLAttr }} class="button">Confirm subscription(s)</a>
<a class="button" {{ .OptinURLAttr }} class="button">{{ .L.T "email.optin.confirmSub" }}</a>
</p>
{{ end }}

View file

@ -1,20 +1,20 @@
{{ define "subscriber-optin" }}
{{ template "header" . }}
<h2>Confirm subscription</h2>
<p>Hi {{ .Subscriber.FirstName }},</p>
<p>You have been added to the following mailing lists:</p>
<h2>{{ .L.T "email.optin.confirmSubTitle" }}</h2>
<p>{{ .L.Ts "email.optin.confirmSubWelcome" "name" .Subscriber.FirstName }}</p>
<p>{{ .L.T "email.optin.confirmSubInfo" }}</p>
<ul>
{{ range $i, $l := .Lists }}
{{ if eq .Type "public" }}
<li>{{ .Name }}</li>
{{ else }}
<li>Private list</li>
<li>{{ .L.T "email.optin.privateList" }}</li>
{{ end }}
{{ end }}
</ul>
<p>Confirm your subscription by clicking the below button.</p>
<p>{{ .L.T "email.optin.confirmSubHelp" }}</p>
<p>
<a href="{{ .OptinURL }}" class="button">Confirm subscription</a>
<a href="{{ .OptinURL }}" class="button">{{ .L.T "email.optin.confirmSub" }}</a>
</p>
{{ template "footer" }}

View file

@ -1,25 +1,27 @@
{{ define "optin" }}
{{ template "header" .}}
<section>
<h2>Confirm</h2>
<h2>{{ .L.T "public.confirmSubTitle" }}</h2>
<p>
You have been added to the following mailing lists:
{{ .L.T "public.confirmSubInfo" }}
</p>
<form method="post">
<ul>
{{ range $i, $l := .Data.Lists }}
<input type="hidden" name="l" value="{{ $l.UUID }}" />
{{ if eq $l.Type "public" }}
{{ if eq $.L.Type "public" }}
<li>{{ $l.Name }}</li>
{{ else }}
<li>Private list</li>
<li>{{ .L.T "public.subPrivateList" }}</li>
{{ end }}
{{ end }}
</ul>
<p>
<input type="hidden" name="confirm" value="true" />
<button type="submit" class="button" id="btn-unsub">Confirm subscription(s)</button>
<button type="submit" class="button" id="btn-unsub">
{{ .L.T "public.confirmSub" }}
</button>
</p>
</form>
</section>